diff --git a/docs/hydrator.md b/docs/hydrator.md index 8cf62e48..7942fb9e 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -133,6 +133,42 @@ You can rename a property without a backwards compatibility break in your stored data by keeping the old serialized name with `NormalizedName`. ::: +## Context + +Both `extract` and `hydrate` accept a context array as last argument. It is +passed to every normalizer and on to nested objects, so it can change how +values are converted for a single call. + +```php +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +$data = $hydrator->extract($event, [DateTimeImmutableNormalizer::FORMAT => 'Y-m-d']); +``` +With the `Context` attribute you can add context for a single property. It is +merged into the context that is passed to the normalizer of this property, and +on to nested objects if the property holds one. Values from the attribute win +over the context of the call. + +```php +use Patchlevel\Hydrator\Attribute\Context; +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +final class Profile +{ + #[Context([DateTimeImmutableNormalizer::FORMAT => 'Y-m-d'])] + public DateTimeImmutable $birthday; + + public DateTimeImmutable $createdAt; +} +``` +The attribute can be used multiple times on the same property. The contexts +are merged in the given order. + +:::note +The context only reaches normalizers. Properties without a normalizer, like +plain strings or integers, are copied as they are. +::: + ## Ignore properties Sometimes it is necessary to exclude properties. You can do that with the diff --git a/docs/normalizer.md b/docs/normalizer.md index 5e0d27fe..4e96b9de 100644 --- a/docs/normalizer.md +++ b/docs/normalizer.md @@ -109,10 +109,26 @@ final class Profile You can read about how the format is structured in the [php docs](https://www.php.net/manual/en/datetime.format.php). ::: +The format can also be changed through the [context](hydrator.md#context) with +the `DateTimeImmutableNormalizer::FORMAT` key. It takes precedence over the +format of the normalizer, for a single call or with the `Context` attribute for +a single property. + +```php +use Patchlevel\Hydrator\Attribute\Context; +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +final class Profile +{ + #[Context([DateTimeImmutableNormalizer::FORMAT => 'Y-m-d'])] + public DateTimeImmutable $birthday; +} +``` ## DateTime The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`, -only for `DateTime` objects. The default format is `DateTime::ATOM`. +only for `DateTime` objects. The default format is `DateTime::ATOM`, and it +reads the same context key, also available as `DateTimeNormalizer::FORMAT`. ```php use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; diff --git a/src/Attribute/Context.php b/src/Attribute/Context.php new file mode 100644 index 00000000..5a187b37 --- /dev/null +++ b/src/Attribute/Context.php @@ -0,0 +1,17 @@ + $context */ + public function __construct( + public readonly array $context, + ) { + } +} diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index e07c681f..034543d0 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -66,7 +66,10 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St : $info->fallback; if ($propertyMetadata->normalizer) { - $fallback = $propertyMetadata->normalizer->normalize($fallback, $context); + $fallback = $propertyMetadata->normalizer->normalize( + $fallback, + $propertyMetadata->context === [] ? $context : [...$context, ...$propertyMetadata->context], + ); } $data[$propertyMetadata->fieldName] = $fallback; @@ -170,7 +173,10 @@ private function resolveSubjectIds( $subjectId = $property->getValue($data); if ($property->normalizer) { - $subjectId = $property->normalizer->normalize($subjectId, $context); + $subjectId = $property->normalizer->normalize( + $subjectId, + $property->context === [] ? $context : [...$context, ...$property->context], + ); } } diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index b3d530d1..da50c5d1 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Metadata; +use Patchlevel\Hydrator\Attribute\Context; use Patchlevel\Hydrator\Attribute\Ignore; use Patchlevel\Hydrator\Attribute\Lazy; use Patchlevel\Hydrator\Attribute\NormalizedName; @@ -131,6 +132,7 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra $type, $fieldName, $this->getNormalizer($reflectionProperty, $type), + context: $this->getContext($reflectionProperty), ); } @@ -160,6 +162,18 @@ private function getFieldName(ReflectionProperty $reflectionProperty): string return $attributeReflectionList[0]->newInstance()->name(); } + /** @return array */ + private function getContext(ReflectionProperty $reflectionProperty): array + { + $context = []; + + foreach ($reflectionProperty->getAttributes(Context::class) as $attributeReflection) { + $context = [...$context, ...$attributeReflection->newInstance()->context]; + } + + return $context; + } + private function hasIgnore(ReflectionProperty $reflectionProperty): bool { return $reflectionProperty->getAttributes(Ignore::class) !== []; diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 3ab336a3..d38a7f7e 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -16,19 +16,24 @@ * fieldName: string, * normalizer: Normalizer|null, * extras: array, + * context?: array, * } */ final class PropertyMetadata { public readonly string $propertyName; - /** @param array $extras */ + /** + * @param array $extras + * @param array $context merged into the context passed to the normalizer + */ public function __construct( public readonly ReflectionProperty $reflection, public readonly Type $type, public string $fieldName, public Normalizer|null $normalizer = null, public array $extras = [], + public array $context = [], ) { $this->propertyName = $reflection->getName(); } @@ -53,6 +58,7 @@ public function __serialize(): array 'fieldName' => $this->fieldName, 'normalizer' => $this->normalizer, 'extras' => $this->extras, + 'context' => $this->context, ]; } @@ -65,5 +71,7 @@ public function __unserialize(array $data): void $this->fieldName = $data['fieldName']; $this->normalizer = $data['normalizer']; $this->extras = $data['extras']; + // metadata cached by an older version has no context + $this->context = $data['context'] ?? []; } } diff --git a/src/Middleware/TransformMiddleware.php b/src/Middleware/TransformMiddleware.php index afc5cffe..7b3b5173 100644 --- a/src/Middleware/TransformMiddleware.php +++ b/src/Middleware/TransformMiddleware.php @@ -61,7 +61,14 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St if ($propertyMetadata->normalizer) { try { /** @psalm-suppress MixedAssignment */ - $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); + $value = $propertyMetadata->normalizer->denormalize( + $data[$propertyMetadata->fieldName], + match (true) { + $propertyMetadata->context === [] => $context, + $context === [] => $propertyMetadata->context, + default => [...$context, ...$propertyMetadata->context], + }, + ); } catch (Throwable $e) { throw new DenormalizationFailure( $metadata->className, @@ -115,7 +122,11 @@ public function extract(ClassMetadata $metadata, object $object, array $context, /** @psalm-suppress MixedAssignment */ $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( $propertyMetadata->getValue($object), - $context, + match (true) { + $propertyMetadata->context === [] => $context, + $context === [] => $propertyMetadata->context, + default => [...$context, ...$propertyMetadata->context], + }, ); } catch (CircularReference $e) { throw $e; diff --git a/src/Normalizer/DateTimeImmutableNormalizer.php b/src/Normalizer/DateTimeImmutableNormalizer.php index e88a7a24..d8a8b269 100644 --- a/src/Normalizer/DateTimeImmutableNormalizer.php +++ b/src/Normalizer/DateTimeImmutableNormalizer.php @@ -12,6 +12,9 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final readonly class DateTimeImmutableNormalizer implements Normalizer { + /** Context key to override the format, e.g. with the Context attribute. */ + public const FORMAT = 'datetime_format'; + public function __construct( private string $format = DateTimeImmutable::ATOM, ) { @@ -28,7 +31,7 @@ public function normalize(mixed $value, array $context): string|null throw InvalidArgument::withWrongType('DateTimeImmutable|null', $value); } - return $value->format($this->format); + return $value->format($this->format($context)); } /** @param array $context */ @@ -42,7 +45,7 @@ public function denormalize(mixed $value, array $context): DateTimeImmutable|nul throw InvalidArgument::withWrongType('string|null', $value); } - $date = DateTimeImmutable::createFromFormat($this->format, $value); + $date = DateTimeImmutable::createFromFormat($this->format($context), $value); if ($date === false) { throw new InvalidArgument(); @@ -50,4 +53,12 @@ public function denormalize(mixed $value, array $context): DateTimeImmutable|nul return $date; } + + /** @param array $context */ + private function format(array $context): string + { + $format = $context[self::FORMAT] ?? null; + + return is_string($format) ? $format : $this->format; + } } diff --git a/src/Normalizer/DateTimeNormalizer.php b/src/Normalizer/DateTimeNormalizer.php index c7aa1d7f..79f4d8f1 100644 --- a/src/Normalizer/DateTimeNormalizer.php +++ b/src/Normalizer/DateTimeNormalizer.php @@ -12,6 +12,9 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final readonly class DateTimeNormalizer implements Normalizer { + /** Context key to override the format, e.g. with the Context attribute. */ + public const FORMAT = 'datetime_format'; + public function __construct( private string $format = DateTime::ATOM, ) { @@ -28,7 +31,7 @@ public function normalize(mixed $value, array $context): string|null throw InvalidArgument::withWrongType('DateTime|null', $value); } - return $value->format($this->format); + return $value->format($this->format($context)); } /** @param array $context */ @@ -42,7 +45,7 @@ public function denormalize(mixed $value, array $context): DateTime|null throw InvalidArgument::withWrongType('string|null', $value); } - $date = DateTime::createFromFormat($this->format, $value); + $date = DateTime::createFromFormat($this->format($context), $value); if ($date === false) { throw new InvalidArgument(); @@ -50,4 +53,12 @@ public function denormalize(mixed $value, array $context): DateTime|null return $date; } + + /** @param array $context */ + private function format(array $context): string + { + $format = $context[self::FORMAT] ?? null; + + return is_string($format) ? $format : $this->format; + } } diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php index d6661e6d..8f585579 100644 --- a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -20,6 +20,7 @@ use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreatedFallbackCallback; +use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataWithContextDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; @@ -223,6 +224,46 @@ public function testDecryptWithFallbackCallback(): void self::assertEquals(new Email('foo@example.com'), $result->email); } + public function testFallbackUsesPropertyContext(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataWithContextDto::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataWithContextDto::class, $result); + self::assertSame('p-fallback-s', $result->email); + } + + public function testSubjectIdUsesPropertyContext(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer + ->expects($this->once()) + ->method('encrypt') + ->with('id-foo', 'p-info@patchlevel.de') + ->willReturn('encrypted'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->extract( + $this->metadata(SensitiveDataWithContextDto::class), + new SensitiveDataWithContextDto('foo', 'info@patchlevel.de'), + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertSame(['id' => 'id-foo', 'email' => 'encrypted'], $result); + } + public function testDecrypt(): void { $cryptographer = $this->createMock(Cryptographer::class); diff --git a/tests/Unit/Extension/Cryptography/Fixture/SensitiveDataWithContextDto.php b/tests/Unit/Extension/Cryptography/Fixture/SensitiveDataWithContextDto.php new file mode 100644 index 00000000..718cd546 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Fixture/SensitiveDataWithContextDto.php @@ -0,0 +1,25 @@ + 'id-'])] + #[DataSubjectId] + public string $id, + #[ContextAwareNormalizer] + #[Context(['prefix' => 'p-', 'suffix' => '-s'])] + #[SensitiveData(fallback: 'fallback')] + public string $email, + ) { + } +} diff --git a/tests/Unit/Fixture/PropertyContextDto.php b/tests/Unit/Fixture/PropertyContextDto.php new file mode 100644 index 00000000..a163b1b9 --- /dev/null +++ b/tests/Unit/Fixture/PropertyContextDto.php @@ -0,0 +1,23 @@ + 'attr-', 'suffix' => '-attr'])] + public string $value, + #[ContextAwareNormalizer] + #[Context(['prefix' => 'first-'])] + #[Context(['prefix' => 'second-', 'suffix' => '-second'])] + public string $repeated, + #[ContextAwareNormalizer] + public string $plain, + ) { + } +} diff --git a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php index 3486bb7d..add9c787 100644 --- a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php +++ b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php @@ -23,6 +23,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithGeneric; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; +use Patchlevel\Hydrator\Tests\Unit\Fixture\PropertyContextDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; use Patchlevel\Hydrator\Tests\Unit\Fixture\Wrapper; use PHPUnit\Framework\Attributes\CoversClass; @@ -351,6 +352,16 @@ public function testIgnore(): void self::assertInstanceOf(IdNormalizer::class, $emailPropertyMetadata->normalizer); } + public function testContext(): void + { + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata(PropertyContextDto::class); + + self::assertSame(['prefix' => 'attr-', 'suffix' => '-attr'], $metadata->properties['value']->context); + self::assertSame(['prefix' => 'second-', 'suffix' => '-second'], $metadata->properties['repeated']->context); + self::assertSame([], $metadata->properties['plain']->context); + } + public function testIgnoreNotFoundProperty(): void { $this->expectException(PropertyMetadataNotFound::class); diff --git a/tests/Unit/Metadata/PropertyMetadataTest.php b/tests/Unit/Metadata/PropertyMetadataTest.php new file mode 100644 index 00000000..8f9b1c9f --- /dev/null +++ b/tests/Unit/Metadata/PropertyMetadataTest.php @@ -0,0 +1,54 @@ + 'attr-'], + ); + + $result = unserialize(serialize($metadata)); + + self::assertInstanceOf(PropertyMetadata::class, $result); + self::assertSame(['prefix' => 'attr-'], $result->context); + } + + public function testUnserializeWithoutContext(): void + { + $metadata = new PropertyMetadata( + new ReflectionProperty(PropertyContextDto::class, 'value'), + Type::string(), + 'value', + context: ['prefix' => 'attr-'], + ); + + // metadata cached by an older version has no context + $data = $metadata->__serialize(); + unset($data['context']); + + $result = (new ReflectionClass(PropertyMetadata::class))->newInstanceWithoutConstructor(); + $result->__unserialize($data); + + self::assertSame([], $result->context); + } +} diff --git a/tests/Unit/Normalizer/DateTimeImmutableNormalizerTest.php b/tests/Unit/Normalizer/DateTimeImmutableNormalizerTest.php index 704e6727..542b692d 100644 --- a/tests/Unit/Normalizer/DateTimeImmutableNormalizerTest.php +++ b/tests/Unit/Normalizer/DateTimeImmutableNormalizerTest.php @@ -67,4 +67,31 @@ public function testDenormalizeWithChangeFormat(): void $normalizer = new DateTimeImmutableNormalizer(format: DateTime::RFC822); $this->assertEquals(new DateTimeImmutable('2015-02-13 22:34:32+01:00'), $normalizer->denormalize('Fri, 13 Feb 15 22:34:32 +0100', [])); } + + public function testNormalizeWithContextFormat(): void + { + $normalizer = new DateTimeImmutableNormalizer(format: DateTime::RFC822); + $this->assertEquals( + '2015-02-13', + $normalizer->normalize(new DateTimeImmutable('2015-02-13 22:34:32+01:00'), [DateTimeImmutableNormalizer::FORMAT => 'Y-m-d']), + ); + } + + public function testDenormalizeWithContextFormat(): void + { + $normalizer = new DateTimeImmutableNormalizer(format: DateTime::RFC822); + $this->assertEquals( + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + $normalizer->denormalize('2015-02-13 22:34:32+01:00', [DateTimeImmutableNormalizer::FORMAT => 'Y-m-d H:i:sP']), + ); + } + + public function testIgnoreInvalidContextFormat(): void + { + $normalizer = new DateTimeImmutableNormalizer(); + $this->assertEquals( + '2015-02-13T22:34:32+01:00', + $normalizer->normalize(new DateTimeImmutable('2015-02-13 22:34:32+01:00'), [DateTimeImmutableNormalizer::FORMAT => 123]), + ); + } } diff --git a/tests/Unit/Normalizer/DateTimeNormalizerTest.php b/tests/Unit/Normalizer/DateTimeNormalizerTest.php index fd6e09e3..b972e21c 100644 --- a/tests/Unit/Normalizer/DateTimeNormalizerTest.php +++ b/tests/Unit/Normalizer/DateTimeNormalizerTest.php @@ -66,4 +66,31 @@ public function testDenormalizeWithChangeFormat(): void $normalizer = new DateTimeNormalizer(format: DateTime::RFC822); $this->assertEquals(new DateTime('2015-02-13 22:34:32+01:00'), $normalizer->denormalize('Fri, 13 Feb 15 22:34:32 +0100', [])); } + + public function testNormalizeWithContextFormat(): void + { + $normalizer = new DateTimeNormalizer(format: DateTime::RFC822); + $this->assertEquals( + '2015-02-13', + $normalizer->normalize(new DateTime('2015-02-13 22:34:32+01:00'), [DateTimeNormalizer::FORMAT => 'Y-m-d']), + ); + } + + public function testDenormalizeWithContextFormat(): void + { + $normalizer = new DateTimeNormalizer(format: DateTime::RFC822); + $this->assertEquals( + new DateTime('2015-02-13 22:34:32+01:00'), + $normalizer->denormalize('2015-02-13 22:34:32+01:00', [DateTimeNormalizer::FORMAT => 'Y-m-d H:i:sP']), + ); + } + + public function testIgnoreInvalidContextFormat(): void + { + $normalizer = new DateTimeNormalizer(); + $this->assertEquals( + '2015-02-13T22:34:32+01:00', + $normalizer->normalize(new DateTime('2015-02-13 22:34:32+01:00'), [DateTimeNormalizer::FORMAT => 123]), + ); + } } diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 8597049b..899fc6a4 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -42,6 +42,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; +use Patchlevel\Hydrator\Tests\Unit\Fixture\PropertyContextDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Skill; use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; @@ -126,6 +127,28 @@ public function testExtractPassesContextToNormalizer(): void self::assertSame(['value' => 'ctx-value'], $data); } + public function testExtractMergesPropertyContext(): void + { + $dto = new PropertyContextDto('value', 'repeated', 'plain'); + + $data = $this->hydrator->extract($dto, ['prefix' => 'ctx-']); + + self::assertSame( + ['value' => 'attr-value', 'repeated' => 'second-repeated', 'plain' => 'ctx-plain'], + $data, + ); + } + + public function testExtractWithOnlyPropertyContext(): void + { + $dto = new PropertyContextDto('value', 'repeated', 'plain'); + + self::assertSame( + ['value' => 'attr-value', 'repeated' => 'second-repeated', 'plain' => 'plain'], + $this->hydrator->extract($dto), + ); + } + public function testExtractCircularReference(): void { $this->expectException(CircularReference::class); @@ -252,6 +275,31 @@ public function testHydratePassesContextToNormalizer(): void self::assertSame('value-ctx', $event->value); } + public function testHydrateMergesPropertyContext(): void + { + $dto = $this->hydrator->hydrate( + PropertyContextDto::class, + ['value' => 'value', 'repeated' => 'repeated', 'plain' => 'plain'], + ['suffix' => '-ctx'], + ); + + self::assertSame('value-attr', $dto->value); + self::assertSame('repeated-second', $dto->repeated); + self::assertSame('plain-ctx', $dto->plain); + } + + public function testHydrateWithOnlyPropertyContext(): void + { + $dto = $this->hydrator->hydrate( + PropertyContextDto::class, + ['value' => 'value', 'repeated' => 'repeated', 'plain' => 'plain'], + ); + + self::assertSame('value-attr', $dto->value); + self::assertSame('repeated-second', $dto->repeated); + self::assertSame('plain', $dto->plain); + } + public function testHydrateUnknownClass(): void { $this->expectException(ClassNotSupported::class);