From 08f21deaa33f8b5706b530c905f3932e0d69d7b6 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 2 Oct 2026 23:15:29 +0200 Subject: [PATCH] Add context attribute for properties The new Context attribute adds context for a single property, which is merged into the context passed to its normalizer and on to nested objects. The date normalizers can now take their format from the context via the datetime_format key. --- docs/hydrator.md | 36 +++++++++++++ docs/normalizer.md | 18 ++++++- src/Attribute/Context.php | 17 ++++++ .../Cryptography/CryptographyMiddleware.php | 10 +++- src/Metadata/AttributeMetadataFactory.php | 14 +++++ src/Metadata/PropertyMetadata.php | 10 +++- src/Middleware/TransformMiddleware.php | 15 +++++- .../DateTimeImmutableNormalizer.php | 15 +++++- src/Normalizer/DateTimeNormalizer.php | 15 +++++- .../CryptographyMiddlewareTest.php | 41 ++++++++++++++ .../Fixture/SensitiveDataWithContextDto.php | 25 +++++++++ tests/Unit/Fixture/PropertyContextDto.php | 23 ++++++++ .../Metadata/AttributeMetadataFactoryTest.php | 11 ++++ tests/Unit/Metadata/PropertyMetadataTest.php | 54 +++++++++++++++++++ .../DateTimeImmutableNormalizerTest.php | 27 ++++++++++ .../Normalizer/DateTimeNormalizerTest.php | 27 ++++++++++ tests/Unit/StackHydratorTest.php | 48 +++++++++++++++++ 17 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 src/Attribute/Context.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/SensitiveDataWithContextDto.php create mode 100644 tests/Unit/Fixture/PropertyContextDto.php create mode 100644 tests/Unit/Metadata/PropertyMetadataTest.php 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);