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