From ee9bd6c9d851557588368bbd24521834ff3e6d40 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 19:33:05 +0200 Subject: [PATCH] Keep the values of an existing object for missing fields Hydrating with OBJECT_TO_POPULATE set promoted properties to their default value if their field was missing, so an update overwrote values which were not part of the data. Defaults are now only used for new objects. The generated mappers get an own populate path for classes with promoted defaults, so the code for new objects stays the same. --- docs/UPGRADE-3.0.md | 29 +++ docs/hydrator.md | 4 + src/Extension/Generated/ClassPlan.php | 12 ++ src/Extension/Generated/CodeEmitter.php | 36 +++- src/Extension/Generated/MapperFiles.php | 2 +- src/Extension/Generated/Templates.php | 16 ++ src/Mapper/ReflectionMapper.php | 13 +- .../Generated/GeneratedStackHydratorTest.php | 69 +++++++ .../Generated/Snapshot/AliasDtoMapper.php | 45 ++++- .../Generated/Snapshot/Circle1DtoMapper.php | 2 +- .../Generated/Snapshot/Circle2DtoMapper.php | 2 +- .../Generated/Snapshot/Circle3DtoMapper.php | 2 +- .../Snapshot/ContextAwareDtoMapper.php | 2 +- .../Generated/Snapshot/DefaultDtoMapper.php | 51 +++++- .../Snapshot/InferNormalizerDtoMapper.php | 2 +- .../InferNormalizerWithIterablesDtoMapper.php | 103 ++++++++++- .../InferNormalizerWithNullableDtoMapper.php | 79 +++++++- .../Snapshot/LazyProfileCreatedMapper.php | 2 +- .../Snapshot/NestedLifecycleDtoMapper.php | 57 +++++- .../NormalizerInBaseClassDefinedDtoMapper.php | 123 ++++++++++++- .../Generated/Snapshot/ParentDtoMapper.php | 2 +- .../Snapshot/PrivateChildDtoMapper.php | 2 +- .../Snapshot/PrivateDefaultChildDtoMapper.php | 172 ++++++++++++++++++ .../Snapshot/PrivatePropertiesDtoMapper.php | 49 ++++- .../Snapshot/ProfileCreatedMapper.php | 2 +- ...ofileCreatedWithInlineNormalizerMapper.php | 2 +- .../Snapshot/ProfileCreatedWrapperMapper.php | 2 +- .../SensitiveDataProfileCreatedMapper.php | 2 +- .../Snapshot/WrongNormalizerMapper.php | 2 +- tests/Unit/Fixture/PrivateDefaultChildDto.php | 10 + .../Unit/Fixture/PrivateDefaultParentDto.php | 18 ++ tests/Unit/StackHydratorTest.php | 33 ++++ 32 files changed, 911 insertions(+), 36 deletions(-) create mode 100644 tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php create mode 100644 tests/Unit/Fixture/PrivateDefaultChildDto.php create mode 100644 tests/Unit/Fixture/PrivateDefaultParentDto.php diff --git a/docs/UPGRADE-3.0.md b/docs/UPGRADE-3.0.md index 8c92e3b..f12edb9 100644 --- a/docs/UPGRADE-3.0.md +++ b/docs/UPGRADE-3.0.md @@ -153,6 +153,35 @@ receive now contains the hydrator under the key `Hydrator::HYDRATOR`. Don't use the key `hydrator` for your own context values. If you serialize, log or compare the context, leave this key out. +### Object to populate + +If you hydrate an existing object with `Hydrator::OBJECT_TO_POPULATE`, missing +fields no longer reset promoted properties to their default value, the object +keeps its current value. If you relied on the reset, pass the default value in +the data. + +before: + +```php +use Patchlevel\Hydrator\Hydrator; + +$profile = new Profile('patchlevel', Role::Admin); + +$hydrator->hydrate(Profile::class, ['name' => 'hydrator'], [Hydrator::OBJECT_TO_POPULATE => $profile]); + +// $profile->role is Role::Member, the default value of the promoted property +``` +after: + +```php +use Patchlevel\Hydrator\Hydrator; + +$profile = new Profile('patchlevel', Role::Admin); + +$hydrator->hydrate(Profile::class, ['name' => 'hydrator'], [Hydrator::OBJECT_TO_POPULATE => $profile]); + +// $profile->role is still Role::Admin +``` ### Wrapping a hydrator If you wrap the hydrator in your own `Hydrator` implementation, add it to the diff --git a/docs/hydrator.md b/docs/hydrator.md index 4ef2e7d..3720993 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -106,6 +106,10 @@ $profile = $hydrator->hydrate( [Hydrator::OBJECT_TO_POPULATE => $profile], ); ``` +Only the fields in the data are set. Properties whose fields are missing keep +their current value, promoted properties are not reset to their default value +either. Defaults are only used for new objects. + ## Rename fields By default, the property name is used to name the field in the extracted diff --git a/src/Extension/Generated/ClassPlan.php b/src/Extension/Generated/ClassPlan.php index 5177fa4..2286a5d 100644 --- a/src/Extension/Generated/ClassPlan.php +++ b/src/Extension/Generated/ClassPlan.php @@ -44,6 +44,18 @@ public function leaf(): bool return true; } + /** Whether missing fields of new objects fall back to the default of a promoted property. */ + public function defaults(): bool + { + foreach ($this->properties as $property) { + if ($property->hasDefault()) { + return true; + } + } + + return false; + } + /** @return array slot => nested class whose objects may be mapped in place */ public function nested(): array { diff --git a/src/Extension/Generated/CodeEmitter.php b/src/Extension/Generated/CodeEmitter.php index c745273..f04660a 100644 --- a/src/Extension/Generated/CodeEmitter.php +++ b/src/Extension/Generated/CodeEmitter.php @@ -78,10 +78,11 @@ public function emit(string $namespace, string $className): string $this->properties[] = sprintf('public NestedMapper $x%d;', $slot); } - [$hydrate, $hydrateHelpers] = $this->hydrateBody(); + [$hydrate, $hydrateHelpers] = $this->hydrateBody(true); + [$populate, $populateHelpers] = $plan->defaults() ? $this->hydrateBody(false) : ['', []]; [$extract, $extractHelpers] = $this->extractBody(); - foreach ([...$hydrateHelpers, ...$extractHelpers] as $helper) { + foreach ([...$hydrateHelpers, ...$populateHelpers, ...$extractHelpers] as $helper) { $initialize[] = $helper; } @@ -95,6 +96,18 @@ public function emit(string $namespace, string $className): string 'class' => $plan->className(), ]); $hydrate = 'return ($this->h)($object, $data, $context, $inline);'; + + if ($populate !== '') { + $this->properties[] = 'public Closure $p;'; + $initialize[] = Templates::render(Templates::CLOSURE, [ + 'slot' => 'p', + 'signature' => 'object $object, array $data, array $context, bool $inline', + 'return' => 'object', + 'body' => $populate, + 'class' => $plan->className(), + ]); + $populate = 'return ($this->p)($object, $data, $context, $inline);'; + } } if ($plan->scopedExtract) { @@ -122,6 +135,11 @@ public function emit(string $namespace, string $className): string 'properties' => implode("\n", $this->properties), // nested objects are only mapped right away for calls of the hydrator which created the mapper 'hydrate' => self::withInline($hydrate, $plan->nested() === [] ? 'false' : Templates::OWNER), + // existing objects keep the values of missing fields, only classes with promoted defaults need own code + 'populateCall' => $populate === '' ? '' : Templates::POPULATE_CALL, + 'populate' => $populate === '' ? '' : Templates::render(Templates::POPULATE, [ + 'body' => self::withInline($populate, $plan->nested() === [] ? 'false' : Templates::OWNER), + ]), 'extract' => self::withInline($extract, $plan->nested() === [] ? 'false' : Templates::OWNER), // the nested entry points are only called by a parent mapper which already did these checks 'hydrateNested' => self::withInline($hydrate, 'true'), @@ -131,7 +149,7 @@ public function emit(string $namespace, string $className): string ]); // empty placeholders leave blank lines behind - $code = preg_replace(["/\n{3,}/", "/{\n\n/"], ["\n\n", "{\n"], $code); + $code = preg_replace(["/\n{3,}/", "/{\n\n/", "/\n\n([ \t]*})/"], ["\n\n", "{\n", "\n$1"], $code); assert($code !== null); return $code; @@ -180,21 +198,23 @@ private function recursive(): string /** * Generates the hydrate code, properties which need another scope are assigned in a helper closure. * + * @param bool $defaults whether missing fields fall back to the default of a promoted property, only for new objects + * * @return array{string, list} body and init code of the helper closures */ - private function hydrateBody(): array + private function hydrateBody(bool $defaults): array { $main = []; $scoped = []; foreach ($this->plan->properties as $property) { if ($property->hydrateScope !== null) { - $scoped[$property->hydrateScope][] = $this->assignment($property); + $scoped[$property->hydrateScope][] = $this->assignment($property, $defaults); continue; } - $main[] = $this->assignment($property); + $main[] = $this->assignment($property, $defaults); } $helpers = []; @@ -215,9 +235,9 @@ private function hydrateBody(): array return [implode("\n\n", $main), $helpers]; } - private function assignment(PropertyPlan $property): string + private function assignment(PropertyPlan $property, bool $defaults): string { - $template = $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN; + $template = $defaults && $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN; $code = []; // the first alias in the data takes the place of the missing field diff --git a/src/Extension/Generated/MapperFiles.php b/src/Extension/Generated/MapperFiles.php index 3fb9034..6fc6ac0 100644 --- a/src/Extension/Generated/MapperFiles.php +++ b/src/Extension/Generated/MapperFiles.php @@ -24,7 +24,7 @@ final class MapperFiles public const NAMESPACE = 'Patchlevel\\Hydrator\\Generated'; /** Part of the name, bump it whenever the generated code changes. */ - public const VERSION = 11; + public const VERSION = 12; public function __construct( public readonly string $cachePath, diff --git a/src/Extension/Generated/Templates.php b/src/Extension/Generated/Templates.php index c0317c3..e4b3bd2 100644 --- a/src/Extension/Generated/Templates.php +++ b/src/Extension/Generated/Templates.php @@ -98,6 +98,7 @@ public function hydrate(array $data, array $context): object if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { $object = $context[Hydrator::OBJECT_TO_POPULATE]; unset($context[Hydrator::OBJECT_TO_POPULATE]); + {{populateCall}} } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -126,6 +127,8 @@ public function extractNested(object $object, array $context): array {{extractNested}} } + {{populate}} + protected function initialize(): void { {{initialize}} @@ -137,6 +140,19 @@ protected function mayReachItself(): bool } } +PHP; + + public const POPULATE_CALL = <<<'PHP' + +return $this->populate($object, $data, $context); +PHP; + + /** Existing objects keep the values of missing fields, the defaults of promoted properties are only set for new objects. */ + public const POPULATE = <<<'PHP' +private function populate(object $object, array $data, array $context): object +{ + {{body}} +} PHP; public const CLOSURE = <<<'PHP' diff --git a/src/Mapper/ReflectionMapper.php b/src/Mapper/ReflectionMapper.php index babcd1c..0468034 100644 --- a/src/Mapper/ReflectionMapper.php +++ b/src/Mapper/ReflectionMapper.php @@ -60,17 +60,15 @@ public function __construct( */ public function hydrate(array $data, array $context): object { - $metadata = $this->metadata; - if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { $object = $context[Hydrator::OBJECT_TO_POPULATE]; // only unset if it is there, unset() copies the context even if the key does not exist unset($context[Hydrator::OBJECT_TO_POPULATE]); - } else { - $object = $metadata->newInstance(); + + return $this->hydrateProperties($object, $data, $context, $this->nested($context), true); } - return $this->hydrateProperties($object, $data, $context, $this->nested($context)); + return $this->hydrateProperties($this->metadata->newInstance(), $data, $context, $this->nested($context)); } /** @@ -78,10 +76,11 @@ public function hydrate(array $data, array $context): object * @param array $data * @param array $context * @param array $nested + * @param bool $populate the object already exists, it keeps the values of missing fields * * @return T */ - private function hydrateProperties(object $object, array $data, array $context, array $nested): object + private function hydrateProperties(object $object, array $data, array $context, array $nested, bool $populate = false): object { $metadata = $this->metadata; $constructorParameters = null; @@ -91,7 +90,7 @@ private function hydrateProperties(object $object, array $data, array $context, $alias = self::alias($propertyMetadata, $data); if ($alias === null) { - if (!$propertyMetadata->reflection->isPromoted()) { + if ($populate || !$propertyMetadata->reflection->isPromoted()) { continue; } diff --git a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php index f76a6f1..138fcfe 100644 --- a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php +++ b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php @@ -60,6 +60,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer; @@ -126,6 +127,7 @@ final class GeneratedStackHydratorTest extends TestCase ProfileCreatedWithInlineNormalizer::class, PrivatePropertiesDto::class, PrivateChildDto::class, + PrivateDefaultChildDto::class, SensitiveDataProfileCreated::class, NestedLifecycleDto::class, AliasDto::class, @@ -509,6 +511,73 @@ public function testExtractWithAlias(): void ); } + public function testHydrateWithObjectToPopulateAndAlias(): void + { + $dto = new AliasDto(Email::fromString('old@patchlevel.de'), 'old'); + + $object = $this->hydrator->hydrate( + AliasDto::class, + ['username' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + self::assertSame($dto, $object); + self::assertSame('new', $dto->name); + self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email); + } + + public function testHydrateWithObjectToPopulateKeepsMissingFields(): void + { + $dto = new DefaultDto('old', Email::fromString('old@patchlevel.de')); + $dto->admin = false; + + $object = $this->hydrator->hydrate( + DefaultDto::class, + ['name' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + // missing fields keep the values of the object, also promoted properties with a default + self::assertSame($dto, $object); + self::assertSame('new', $dto->name); + self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email); + self::assertFalse($dto->admin); + } + + public function testHydrateWithObjectToPopulateKeepsMissingPrivateFields(): void + { + $dto = new PrivatePropertiesDto(ProfileId::fromString('1'), 'old', 30); + + $object = $this->hydrator->hydrate( + PrivatePropertiesDto::class, + ['name' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + self::assertSame($dto, $object); + self::assertSame('1', $dto->profileId()->toString()); + self::assertSame('new', $dto->name()); + self::assertSame(30, $dto->age()); + } + + public function testHydrateWithObjectToPopulateKeepsMissingPrivateFieldsOfParent(): void + { + $dto = new PrivateDefaultChildDto('kept'); + + $object = $this->hydrator->hydrate( + PrivateDefaultChildDto::class, + ['name' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + self::assertSame($dto, $object); + self::assertSame('new', $dto->name); + self::assertSame('kept', $dto->note()); + + // new objects still fall back to the default + self::assertSame('default', $this->hydrator->hydrate(PrivateDefaultChildDto::class, [])->note()); + } + public function testHydrateWithTypeMismatch(): void { $this->expectException(TypeMismatch::class); diff --git a/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php index 3b7221d..f6c362f 100644 --- a/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -227,6 +229,47 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + 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); + } + } + + return $object; + } + protected function initialize(): void { $this->n0 = $this->normalizer('email'); diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php index edcac33..f7991a1 100644 --- a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -254,6 +256,53 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; + + if (\array_key_exists('admin', $data)) { + $value = $data['admin']; + + try { + $object->admin = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'admin', $e); + } + } + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'name', $e); + } + } + + if (\array_key_exists('email', $data)) { + try { + $value = $data['email']; + + if ($this->i0 && $inline && \is_array($value)) { + $value = $this->x0->hydrateNested($value, $context); + } else { + $value = $this->n0->denormalize($value, $context); + } + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e); + } + + try { + $object->email = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $e); + } + } + + return $object; + } + protected function initialize(): void { $this->d0 = $this->promotedDefault('email'); diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php index 505368e..acfbf13 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -552,6 +554,105 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + if (\array_key_exists('defaultArray', $data)) { + try { + $value = $this->n0->denormalize($data['defaultArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e); + } + + try { + $object->defaultArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $e); + } + } + + if (\array_key_exists('listArray', $data)) { + try { + $value = $this->n1->denormalize($data['listArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e); + } + + try { + $object->listArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $e); + } + } + + if (\array_key_exists('iterableArray', $data)) { + try { + $value = $this->n2->denormalize($data['iterableArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e); + } + + try { + $object->iterableArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $e); + } + } + + if (\array_key_exists('hashMap', $data)) { + try { + $value = $this->n3->denormalize($data['hashMap'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e); + } + + try { + $object->hashMap = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $e); + } + } + + if (\array_key_exists('nested', $data)) { + try { + $value = $this->n4->denormalize($data['nested'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e); + } + + try { + $object->nested = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $e); + } + } + + if (\array_key_exists('jsonArray', $data)) { + $value = $data['jsonArray']; + + try { + $object->jsonArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'jsonArray', $e); + } + } + + if (\array_key_exists('shapeArray', $data)) { + try { + $value = $this->n5->denormalize($data['shapeArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e); + } + + try { + $object->shapeArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $e); + } + } + + return $object; + } + protected function initialize(): void { $this->n0 = $this->normalizer('defaultArray'); diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php index 891b8fd..f777aa6 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -447,6 +449,81 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + if (\array_key_exists('status', $data)) { + try { + $value = $this->n0->denormalize($data['status'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e); + } + + try { + $object->status = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $e); + } + } + + if (\array_key_exists('dateTimeImmutable', $data)) { + try { + $value = $this->n1->denormalize($data['dateTimeImmutable'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e); + } + + try { + $object->dateTimeImmutable = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $e); + } + } + + if (\array_key_exists('dateTime', $data)) { + try { + $value = $this->n2->denormalize($data['dateTime'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e); + } + + try { + $object->dateTime = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $e); + } + } + + if (\array_key_exists('dateTimeZone', $data)) { + try { + $value = $this->n3->denormalize($data['dateTimeZone'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e); + } + + try { + $object->dateTimeZone = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $e); + } + } + + if (\array_key_exists('profileId', $data)) { + try { + $value = $this->n4->denormalize($data['profileId'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e); + } + + try { + $object->profileId = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $e); + } + } + + return $object; + } + protected function initialize(): void { $this->n0 = $this->normalizer('status'); diff --git a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php index ff94539..3474d62 100644 --- a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -344,6 +346,59 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; + + if (\array_key_exists('child', $data)) { + try { + $value = $data['child']; + + if ($this->i0 && $inline && \is_array($value)) { + $value = $this->x0->hydrateNested($value, $context); + } else { + $value = $this->n0->denormalize($value, $context); + } + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e); + } + + try { + $object->child = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $e); + } + } + + if (\array_key_exists('items', $data)) { + try { + $value = $data['items']; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = \is_array($item) ? $this->x0->hydrateNested($item, $context) : $this->n2->denormalize($item, $context); + } + + $value = $items; + } else { + $value = $this->n1->denormalize($value, $context); + } + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e); + } + + try { + $object->items = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $e); + } + } + + return $object; + } + protected function initialize(): void { $this->n0 = $this->normalizer('child'); diff --git a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php index a4a620d..bc39ee9 100644 --- a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -645,6 +647,125 @@ public function extractNested(object $object, array $context): array ]; } + private function populate(object $object, array $data, array $context): object + { + $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; + + if (\array_key_exists('status', $data)) { + try { + $value = $this->n0->denormalize($data['status'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e); + } + + try { + $object->status = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $e); + } + } + + if (\array_key_exists('profileCreated', $data)) { + try { + $value = $data['profileCreated']; + + if ($this->i0 && $inline && \is_array($value)) { + $value = $this->x0->hydrateNested($value, $context); + } else { + $value = $this->n1->denormalize($value, $context); + } + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e); + } + + try { + $object->profileCreated = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $e); + } + } + + if (\array_key_exists('defaultArray', $data)) { + try { + $value = $this->n2->denormalize($data['defaultArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e); + } + + try { + $object->defaultArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $e); + } + } + + if (\array_key_exists('listArray', $data)) { + try { + $value = $this->n3->denormalize($data['listArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e); + } + + try { + $object->listArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $e); + } + } + + if (\array_key_exists('iterableArray', $data)) { + try { + $value = $this->n4->denormalize($data['iterableArray'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e); + } + + try { + $object->iterableArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $e); + } + } + + if (\array_key_exists('skillsHashMap', $data)) { + try { + $value = $data['skillsHashMap']; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = \is_array($item) ? $this->x1->hydrateNested($item, $context) : $this->n6->denormalize($item, $context); + } + + $value = $items; + } else { + $value = $this->n5->denormalize($value, $context); + } + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e); + } + + try { + $object->skillsHashMap = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $e); + } + } + + if (\array_key_exists('jsonArray', $data)) { + $value = $data['jsonArray']; + + try { + $object->jsonArray = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'jsonArray', $e); + } + } + + return $object; + } + protected function initialize(): void { $this->n0 = $this->normalizer('status'); diff --git a/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php index 5c8c3c9..51564a2 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php @@ -1,7 +1,7 @@ ready) { + $this->init(); + } + + if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { + $object = $context[Hydrator::OBJECT_TO_POPULATE]; + unset($context[Hydrator::OBJECT_TO_POPULATE]); + + return $this->populate($object, $data, $context); + } else { + $object = $this->reflection->newInstanceWithoutConstructor(); + } + + $inline = false; + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto::class, 'name', $e); + } + } + + ($this->hp0)($object, $data, $context, $inline); + + return $object; + } + + public function extract(object $object, array $context): array + { + if (!$this->ready) { + $this->init(); + } + + $inline = false; + + [$v_note] = ($this->ep2)($object, $context, $inline); + + return [ + 'name' => $object->name, + 'note' => $v_note, + ]; + } + + public function hydrateNested(array $data, array $context): object + { + $object = $this->reflection->newInstanceWithoutConstructor(); + + $inline = true; + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto::class, 'name', $e); + } + } + + ($this->hp0)($object, $data, $context, $inline); + + return $object; + } + + public function extractNested(object $object, array $context): array + { + $inline = true; + + [$v_note] = ($this->ep2)($object, $context, $inline); + + return [ + 'name' => $object->name, + 'note' => $v_note, + ]; + } + + private function populate(object $object, array $data, array $context): object + { + $inline = false; + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto::class, 'name', $e); + } + } + + ($this->hp1)($object, $data, $context, $inline); + + return $object; + } + + protected function initialize(): void + { + $this->hp0 = Closure::bind(function (object $object, array $data, array $context, bool $inline): void { + if (\array_key_exists('note', $data)) { + $value = $data['note']; + + try { + $object->note = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto::class, 'note', $e); + } + } else { + $object->note = 'default'; + } + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultParentDto::class); + $this->hp1 = Closure::bind(function (object $object, array $data, array $context, bool $inline): void { + if (\array_key_exists('note', $data)) { + $value = $data['note']; + + try { + $object->note = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto::class, 'note', $e); + } + } + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultParentDto::class); + $this->ep2 = Closure::bind(function (object $object, array $context, bool $inline): array { + return [$object->note]; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultParentDto::class); + } + + protected function mayReachItself(): bool + { + return false; + } +} \ No newline at end of file diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php index 9265c88..6b875af 100644 --- a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -79,6 +82,13 @@ public function extractNested(object $object, array $context): array return ($this->e)($object, $context, $inline); } + private function populate(object $object, array $data, array $context): object + { + $inline = false; + + return ($this->p)($object, $data, $context, $inline); + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); @@ -121,6 +131,43 @@ protected function initialize(): void return $object; }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class); + $this->p = Closure::bind(function (object $object, array $data, array $context, bool $inline): object { + if (\array_key_exists('profileId', $data)) { + try { + $value = $this->n0->denormalize($data['profileId'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e); + } + + try { + $object->profileId = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $e); + } + } + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'name', $e); + } + } + + if (\array_key_exists('age', $data)) { + $value = $data['age']; + + try { + $object->age = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'age', $e); + } + } + + return $object; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class); $this->e = Closure::bind(function (object $object, array $context, bool $inline): array { if ($this->tracked) { $id = \spl_object_id($object); diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php index 4302d55..23e8c32 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php @@ -1,7 +1,7 @@ note; + } +} diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 53eee9b..cceec57 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -374,6 +374,39 @@ public function testExtractWithAlias(): void ); } + public function testHydrateWithObjectToPopulateAndAlias(): void + { + $dto = new AliasDto(Email::fromString('old@patchlevel.de'), 'old'); + + $object = $this->hydrator->hydrate( + AliasDto::class, + ['username' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + self::assertSame($dto, $object); + self::assertSame('new', $dto->name); + self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email); + } + + public function testHydrateWithObjectToPopulateKeepsMissingFields(): void + { + $dto = new DefaultDto('old', Email::fromString('old@patchlevel.de')); + $dto->admin = false; + + $object = $this->hydrator->hydrate( + DefaultDto::class, + ['name' => 'new'], + [Hydrator::OBJECT_TO_POPULATE => $dto], + ); + + // missing fields keep the values of the object, also promoted properties with a default + self::assertSame($dto, $object); + self::assertSame('new', $dto->name); + self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email); + self::assertFalse($dto->admin); + } + public function testHydrateWithTypeMismatch(): void { $this->expectException(TypeMismatch::class);