From ee9bd6c9d851557588368bbd24521834ff3e6d40 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 19:33:05 +0200 Subject: [PATCH 1/2] 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); From bd7b2a03765d74b2fbc445853cbec1183e4b374c Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 19:35:23 +0200 Subject: [PATCH 2/2] Bring the groups extension to 3.0 The GroupsMiddleware of 2.1 passed filtered metadata down the stack, which the mapper chain of 3.0 does not allow anymore. The groups are now applied by the mappers themselves through a new PropertyFilter, which extensions register on the builder. The GroupsExtension registers a GroupsPropertyFilter instead of the middleware. The ReflectionMapper only reads the properties the filter keeps and treats the fields of the others as missing. The generated mappers get an extractFiltered() method and a FilteredMapper which applies the filter around them. The factory only adds it for hydrators with a property filter, so the generated code of all other hydrators stays the same. --- docs/UPGRADE-3.0.md | 31 ++ docs/extensions.md | 76 ++++- docs/generated-code.md | 5 +- docs/groups.md | 165 +++++++++++ docs/project.json | 1 + src/Extension/Generated/CodeEmitter.php | 99 ++++++- src/Extension/Generated/FilteredMapper.php | 132 +++++++++ src/Extension/Generated/GeneratedMapper.php | 28 +- .../Generated/GeneratedMapperFactory.php | 8 +- src/Extension/Generated/MapperFiles.php | 2 +- src/Extension/Generated/Templates.php | 39 ++- src/Extension/Groups/Attribute/Groups.php | 23 ++ src/Extension/Groups/GroupsExtension.php | 26 ++ .../Groups/GroupsMetadataEnricher.php | 35 +++ src/Extension/Groups/GroupsPropertyFilter.php | 147 ++++++++++ src/Mapper/ChainPropertyFilter.php | 49 ++++ src/Mapper/PropertyFilter.php | 32 ++ src/Mapper/ReflectionMapper.php | 48 ++- src/StackHydrator.php | 25 +- src/StackHydratorBuilder.php | 23 ++ .../NoCodeGenerationAtRuntimeTest.php | 2 + .../Generated/GeneratedStackHydratorTest.php | 212 +++++++++++++- .../Generated/MapperGeneratorTest.php | 11 + .../Generated/Snapshot/AliasDtoMapper.php | 62 +++- .../Generated/Snapshot/Circle1DtoMapper.php | 56 +++- .../Generated/Snapshot/Circle2DtoMapper.php | 56 +++- .../Generated/Snapshot/Circle3DtoMapper.php | 56 +++- .../Snapshot/ContextAwareDtoMapper.php | 54 +++- .../Generated/Snapshot/DefaultDtoMapper.php | 72 ++++- .../Snapshot/InferNormalizerDtoMapper.php | 134 ++++++++- .../InferNormalizerWithIterablesDtoMapper.php | 182 +++++++++++- .../InferNormalizerWithNullableDtoMapper.php | 150 +++++++++- .../Snapshot/LazyProfileCreatedMapper.php | 78 ++++- .../Snapshot/NestedLifecycleDtoMapper.php | 104 ++++++- .../NormalizerInBaseClassDefinedDtoMapper.php | 208 ++++++++++++- .../Generated/Snapshot/ParentDtoMapper.php | 78 ++++- .../Snapshot/PrivateChildDtoMapper.php | 108 ++++++- .../Snapshot/PrivateDefaultChildDtoMapper.php | 29 +- .../Snapshot/PrivatePropertiesDtoMapper.php | 74 ++++- .../Snapshot/ProfileCreatedMapper.php | 78 ++++- ...ofileCreatedWithInlineNormalizerMapper.php | 78 ++++- .../Snapshot/ProfileCreatedWrapperMapper.php | 56 +++- .../SensitiveDataProfileCreatedMapper.php | 78 ++++- .../Snapshot/WrongNormalizerMapper.php | 54 +++- .../Groups/Fixture/AddressFixture.php | 18 ++ .../Extension/Groups/Fixture/ChildFixture.php | 18 ++ .../Groups/Fixture/NoGroupsFixture.php | 14 + .../Groups/Fixture/ParentFixture.php | 18 ++ .../Extension/Groups/Fixture/UserFixture.php | 23 ++ .../Extension/Groups/GroupsExtensionTest.php | 274 ++++++++++++++++++ .../Groups/GroupsMetadataEnricherTest.php | 62 ++++ .../Groups/GroupsPropertyFilterTest.php | 191 ++++++++++++ tests/Unit/Fixture/ContextPropertyFilter.php | 53 ++++ tests/Unit/Mapper/ChainPropertyFilterTest.php | 57 ++++ tests/Unit/StackHydratorBuilderTest.php | 19 ++ tests/Unit/StackHydratorTest.php | 151 ++++++++++ 56 files changed, 3909 insertions(+), 53 deletions(-) create mode 100644 docs/groups.md create mode 100644 src/Extension/Generated/FilteredMapper.php create mode 100644 src/Extension/Groups/Attribute/Groups.php create mode 100644 src/Extension/Groups/GroupsExtension.php create mode 100644 src/Extension/Groups/GroupsMetadataEnricher.php create mode 100644 src/Extension/Groups/GroupsPropertyFilter.php create mode 100644 src/Mapper/ChainPropertyFilter.php create mode 100644 src/Mapper/PropertyFilter.php create mode 100644 tests/Unit/Extension/Groups/Fixture/AddressFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ChildFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/NoGroupsFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ParentFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/UserFixture.php create mode 100644 tests/Unit/Extension/Groups/GroupsExtensionTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsPropertyFilterTest.php create mode 100644 tests/Unit/Fixture/ContextPropertyFilter.php create mode 100644 tests/Unit/Mapper/ChainPropertyFilterTest.php diff --git a/docs/UPGRADE-3.0.md b/docs/UPGRADE-3.0.md index f12edb9..40bd55b 100644 --- a/docs/UPGRADE-3.0.md +++ b/docs/UPGRADE-3.0.md @@ -378,6 +378,37 @@ use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; $extension = new UpcastExtension(beforeMapping: [$mergeNameUpcaster]); ``` +## Groups + +### GroupsMiddleware + +The `GroupsMiddleware` has been removed. The groups are now applied by the +mapper of every class through the `GroupsPropertyFilter`, which also works with +the generated mappers. If you use the `GroupsExtension`, nothing changes. If you +registered the middleware yourself, register the property filter instead, or +use the `GroupsExtension`. + +before: + +```php +use Patchlevel\Hydrator\Extension; +use Patchlevel\Hydrator\Extension\Groups\GroupsMetadataEnricher; +use Patchlevel\Hydrator\Extension\Groups\GroupsMiddleware; + +$builder + ->addMiddleware(new GroupsMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM) + ->addMetadataEnricher(new GroupsMetadataEnricher()); +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsMetadataEnricher; +use Patchlevel\Hydrator\Extension\Groups\GroupsPropertyFilter; + +$builder + ->addPropertyFilter(new GroupsPropertyFilter()) + ->addMetadataEnricher(new GroupsMetadataEnricher()); +``` ## Caching The serialized form of the normalizers changed. Clear the diff --git a/docs/extensions.md b/docs/extensions.md index 8bedc56..9d92b0e 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -2,9 +2,10 @@ The `StackHydrator` is assembled from small building blocks: middlewares that wrap the hydration process, [guessers](guesser.md) that resolve normalizers, -metadata enrichers that add information to the class metadata and mapper -factories that map the data to the object. An extension bundles such building -blocks so they can be registered with a single call. +metadata enrichers that add information to the class metadata, mapper +factories that map the data to the object and property filters that decide +which properties are mapped. An extension bundles such building blocks so they +can be registered with a single call. ## Using extensions @@ -23,13 +24,14 @@ $hydrator = (new StackHydratorBuilder()) ``` ## Built-in extensions -The library ships with four extensions out of the box: +The library ships with five extensions out of the box: | Extension | Purpose | | --- | --- | | `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | | `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | | `UpcastExtension` | [Upcasting](upcasting.md), reshape outdated stored data while it is hydrated. | +| `GroupsExtension` | [Groups](groups.md), extract and hydrate only the properties of selected groups. | | `GeneratedMapperExtension` | [Generated code](generated-code.md), use generated mapping code instead of reflection. | ## Middleware @@ -210,10 +212,73 @@ Ask for the mappers of nested classes once the mapper is used, not inside two classes which reference each other would create each other endlessly. ::: +:::warning +Your mapper has to apply the [property filter](#property-filters) of the +hydrator, otherwise extensions like the [groups](groups.md) have no effect on +its class. `StackHydrator::propertyFilter()` returns it, or `null` if there is +none. +::: + +## Property filters + +A property filter decides which properties of a class are mapped for a call, +based on the context. The [groups](groups.md) are built on it. The filter +returns the properties to map, keyed by their field name and in the order of +the metadata, or `null` to map every property. + +```php +use Patchlevel\Hydrator\Mapper\PropertyFilter; +use Patchlevel\Hydrator\Metadata\ClassMetadata; + +final class SparseFieldsetFilter implements PropertyFilter +{ + public function filter(ClassMetadata $metadata, array $context): array|null + { + $fields = $context['fields'][$metadata->className] ?? null; + + if (!is_array($fields)) { + return null; + } + + $properties = []; + + foreach ($metadata->properties as $property) { + if (!in_array($property->fieldName, $fields, true)) { + continue; + } + + $properties[$property->fieldName] = $property; + } + + return $properties; + } +} +``` +```php +$builder->addPropertyFilter(new SparseFieldsetFilter()); + +$hydrator->extract($event, ['fields' => [ProfileCreated::class => ['id', 'name']]]); +``` +While extracting, the mapper does not read the properties outside the filter, +so they may be uninitialized and they don't lead to circular references. While +hydrating, their fields are treated as missing: new objects fall back to the +default value of promoted properties, and an +[object to populate](hydrator.md#object-to-populate) keeps its values. + +If you register more than one filter, a property is only mapped if every +filter keeps it. + +:::tip +The filter is called for every mapped object. Return `null` as early as +possible if the context does not concern the filter, and cache the result if it +is expensive to build. +::: + ## Writing your own extension An extension implements the `Extension` interface and configures the builder. -This is the way to package a middleware together with its metadata enricher. +This is the way to package a middleware or a property filter together with its +metadata enricher. ```php use Patchlevel\Hydrator\Extension; @@ -233,5 +298,6 @@ final class AuditExtension implements Extension * [How to run code before extract and after hydrate](lifecycle-hooks.md) * [How to encrypt sensitive data](cryptography.md) * [How to reshape outdated stored data](upcasting.md) +* [How to extract and hydrate only some properties with groups](groups.md) * [How to speed up hydration with generated code](generated-code.md) * [How to cache the metadata](caching.md) diff --git a/docs/generated-code.md b/docs/generated-code.md index 6d003b1..0e165dd 100644 --- a/docs/generated-code.md +++ b/docs/generated-code.md @@ -32,8 +32,9 @@ directory. Classes without one are mapped with reflection as usual. The generated code behaves exactly like the `ReflectionMapper`: objects are created without calling the constructor, missing fields keep their promoted default value, private and readonly properties are supported and the same -exceptions are thrown. You can switch between both without changing your -classes. +exceptions are thrown. [Groups](groups.md) and other +[property filters](extensions.md#property-filters) are applied as well. You can +switch between both without changing your classes. ::: ## Warm up diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..aa08a86 --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,165 @@ +# Groups + +Often you don't want to extract every property of an object, for example +when the same class is exposed to the public and to admins, or when only a +part of the data should be hydrated. The `GroupsExtension` lets you assign +properties to groups and select the groups via the context, similar to the +groups of the Symfony Serializer. + +## Setup + +Register the `GroupsExtension` on the builder: + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new GroupsExtension()) + ->build(); +``` +The extension registers a [property filter](extensions.md#property-filters), +so the groups are applied by the mapper of every class, the `ReflectionMapper` +as well as the [generated mappers](generated-code.md). + +## Define groups + +Mark the properties with the `Groups` attribute. It accepts a single group or +a list of groups. + +```php +use Patchlevel\Hydrator\Extension\Groups\Attribute\Groups; + +final class User +{ + public function __construct( + #[Groups(['public', 'admin'])] + public string $id, + #[Groups('public')] + public string $name, + #[Groups('admin')] + public string $email, + public string $passwordHash, + ) { + } +} +``` +## Select groups + +Pass the groups with the `GroupsExtension::GROUPS` context key, either as a +string or as a list of strings. Only properties that are in at least one of +the given groups are extracted. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::GROUPS => 'public']); +// ['id' => '...', 'name' => '...'] + +$hydrator->extract($user, [GroupsExtension::GROUPS => ['public', 'admin']]); +// ['id' => '...', 'name' => '...', 'email' => '...'] +``` +Without groups in the context, all properties are used, so the extension does +not change anything until you ask for it. Properties without the `Groups` +attribute are excluded as soon as groups are selected. Use the wildcard group +`GroupsExtension::ALL` (`*`) to include every property again. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::GROUPS => GroupsExtension::ALL]); +``` +:::note +The context is passed on to nested objects, so their properties are filtered +by the same groups. Make sure nested classes have the `Groups` attribute as +well, otherwise they are extracted as an empty array. +::: + +## Ignore groups + +The other way around, `GroupsExtension::IGNORED_GROUPS` excludes every +property that is in at least one of the given groups. Properties without the +`Groups` attribute are kept, so you can hide a few properties without tagging +all the others. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::IGNORED_GROUPS => 'admin']); +// ['name' => '...', 'passwordHash' => '...'] +``` +Both options can be combined. The ignored groups win, so a property that is in +a selected and in an ignored group is excluded. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [ + GroupsExtension::GROUPS => ['public', 'admin'], + GroupsExtension::IGNORED_GROUPS => 'admin', +]); +// ['name' => '...'] +``` +## Circular references + +Since properties outside the selected groups are never read, groups are also a +way to break circular references. Put the back reference into a different +group than the forward reference: + +```php +use Patchlevel\Hydrator\Extension\Groups\Attribute\Groups; +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +final class Author +{ + #[Groups(['author', 'book'])] + public string $name; + + #[Groups('author')] + public Book $book; +} + +final class Book +{ + #[Groups(['author', 'book'])] + public string $title; + + #[Groups('book')] + public Author $author; +} + +$hydrator->extract($author, [GroupsExtension::GROUPS => 'author']); +// ['name' => '...', 'book' => ['title' => '...']] +``` +:::warning +Without groups, or with the wildcard group, every property is followed again +and a `CircularReference` exception is thrown as usual. +::: + +## Hydrate with groups + +Groups also work while hydrating. Fields of properties outside the selected +groups are ignored, even if they are present in the data. Promoted properties +with a default value fall back to it, all others stay uninitialized. An +existing object keeps the values of these properties. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$user = $hydrator->hydrate( + User::class, + ['id' => '1', 'name' => 'John', 'email' => 'john@example.com'], + [GroupsExtension::GROUPS => 'public'], +); +``` +:::tip +Combine it with `Hydrator::OBJECT_TO_POPULATE` to update only the properties +of a specific group on an existing object. +::: + +## Learn more + +* [How extensions and property filters work](extensions.md) +* [How to use the hydrator](hydrator.md) +* [How to ignore a property completely](hydrator.md#ignore-properties) +* [How to speed up hydration with generated code](generated-code.md) diff --git a/docs/project.json b/docs/project.json index d7fd1f3..348791c 100644 --- a/docs/project.json +++ b/docs/project.json @@ -19,6 +19,7 @@ { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, { "title": "Cryptography", "file": "cryptography.md" }, { "title": "Upcasting", "file": "upcasting.md" }, + { "title": "Groups", "file": "groups.md" }, { "title": "Generated Code", "file": "generated-code.md" } ] }, diff --git a/src/Extension/Generated/CodeEmitter.php b/src/Extension/Generated/CodeEmitter.php index f04660a..18b550f 100644 --- a/src/Extension/Generated/CodeEmitter.php +++ b/src/Extension/Generated/CodeEmitter.php @@ -81,8 +81,9 @@ public function emit(string $namespace, string $className): string [$hydrate, $hydrateHelpers] = $this->hydrateBody(true); [$populate, $populateHelpers] = $plan->defaults() ? $this->hydrateBody(false) : ['', []]; [$extract, $extractHelpers] = $this->extractBody(); + [$extractFiltered, $filteredHelpers] = $this->extractFilteredBody(); - foreach ([...$hydrateHelpers, ...$populateHelpers, ...$extractHelpers] as $helper) { + foreach ([...$hydrateHelpers, ...$populateHelpers, ...$extractHelpers, ...$filteredHelpers] as $helper) { $initialize[] = $helper; } @@ -120,6 +121,16 @@ public function emit(string $namespace, string $className): string 'class' => $plan->className(), ]); $extract = 'return ($this->e)($object, $context, $inline);'; + + $this->properties[] = 'public Closure $f;'; + $initialize[] = Templates::render(Templates::CLOSURE, [ + 'slot' => 'f', + 'signature' => 'object $object, array $context, array $fields, bool $inline', + 'return' => 'array', + 'body' => $extractFiltered, + 'class' => $plan->className(), + ]); + $extractFiltered = 'return ($this->f)($object, $context, $fields, $inline);'; } foreach ($plan->nested() as $slot => $nested) { @@ -144,6 +155,7 @@ public function emit(string $namespace, string $className): string // the nested entry points are only called by a parent mapper which already did these checks 'hydrateNested' => self::withInline($hydrate, 'true'), 'extractNested' => self::withInline($extract, 'true'), + 'extractFiltered' => $extractFiltered, 'initialize' => $initialize === [] ? '// nothing to resolve' : implode("\n", $initialize), 'recursive' => $this->recursive(), ]); @@ -355,21 +367,88 @@ private function extractBody(): array array_unshift($pre, sprintf('[%s] = ($this->ep%d)($object, $context, $inline);', implode(', ', $variables), $slot)); } - $variables = [ - 'class' => $this->plan->className(), + $body = Templates::render(Templates::EXTRACT_PLAIN, [ 'pre' => implode("\n\n", $pre), 'fields' => implode("\n", $fields), - ]; + ]); + + return [$this->tracked($body), $helpers]; + } + + /** + * Generates the extract code for calls with a property filter, it only reads the properties in $fields. Properties + * which need another scope are read in a helper closure, which only returns the values of the kept properties. + * + * @return array{string, list} body and init code of the helper closures + */ + private function extractFilteredBody(): array + { + $fields = []; + $scoped = []; + + /** @var array $slots */ + $slots = []; + + foreach ($this->plan->properties as $property) { + $field = var_export($property->field, true); + [$code, $expression] = $this->normalize($property); + $block = Templates::render(Templates::EXTRACT_FILTERED_FIELD, [ + 'field' => $field, + 'code' => $code, + 'value' => $expression, + ]); + if ($property->extractScope === null) { + $fields[] = $block; + + continue; + } + + $scope = $property->extractScope; + $slot = $slots[$scope] ??= $this->helperSlots++; + $scoped[$scope][] = $block; + $fields[] = Templates::render(Templates::EXTRACT_FILTERED_FIELD, [ + 'field' => $field, + 'code' => '', + 'value' => sprintf('$s%d[%s]', $slot, $field), + ]); + } + + $pre = []; + $helpers = []; + + foreach ($scoped as $scope => $blocks) { + $slot = $slots[$scope]; + $this->properties[] = sprintf('public Closure $fp%d;', $slot); + + $helpers[] = Templates::render(Templates::EXTRACT_FILTERED_HELPER, [ + 'slot' => (string)$slot, + 'scope' => $scope, + 'body' => implode("\n\n", $blocks), + ]); + + $pre[] = sprintf('$s%1$d = ($this->fp%1$d)($object, $context, $fields, $inline);', $slot); + } + + $body = Templates::render(Templates::EXTRACT_FILTERED, [ + 'pre' => implode("\n", $pre), + 'fields' => implode("\n\n", $fields), + ]); + + return [$this->tracked($body), $helpers]; + } + + /** Classes which may lead back to themselves track the extracted objects to detect circular references. */ + private function tracked(string $body): string + { if ($this->plan->leaf()) { - return [Templates::render(Templates::EXTRACT_PLAIN, $variables), $helpers]; + return $body; } - return [ - Templates::render(Templates::EXTRACT_TRACKED, $variables) . "\n\n" - . Templates::render(Templates::EXTRACT_PLAIN, $variables), - $helpers, - ]; + return Templates::render(Templates::EXTRACT_TRACKED, [ + 'class' => $this->plan->className(), + 'body' => $body, + ]) . "\n\n" . $body; } /** @return array{string, string} preparing code and the expression for the normalized value */ diff --git a/src/Extension/Generated/FilteredMapper.php b/src/Extension/Generated/FilteredMapper.php new file mode 100644 index 0000000..88c4064 --- /dev/null +++ b/src/Extension/Generated/FilteredMapper.php @@ -0,0 +1,132 @@ + + */ +final class FilteredMapper implements Mapper, NestedMapper +{ + /** @param GeneratedMapper&Mapper $mapper */ + public function __construct( + public readonly GeneratedMapper $mapper, + private readonly PropertyFilter $filter, + ) { + } + + /** + * @param array $data + * @param array $context + * + * @return T + */ + public function hydrate(array $data, array $context): object + { + $fields = $this->filter->filter($this->mapper->metadata, $context); + + if ($fields !== null) { + $data = self::withoutFilteredFields($this->mapper->metadata, $data, $fields); + } + + return $this->mapper->hydrate($data, $context); + } + + /** + * @param T $object + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context): array + { + $fields = $this->filter->filter($this->mapper->metadata, $context); + + if ($fields === null) { + return $this->mapper->extract($object, $context); + } + + if (!$this->mapper->ready) { + $this->mapper->init(); + } + + // nested objects are only mapped right away for calls of the hydrator which created the mapper + return $this->mapper->extractFiltered( + $object, + $context, + $fields, + ($context[Hydrator::HYDRATOR] ?? null) === $this->mapper->hydrator, + ); + } + + /** + * @param array $data + * @param array $context + */ + public function hydrateNested(array $data, array $context): object + { + $fields = $this->filter->filter($this->mapper->metadata, $context); + + if ($fields !== null) { + $data = self::withoutFilteredFields($this->mapper->metadata, $data, $fields); + } + + return $this->mapper->hydrateNested($data, $context); + } + + /** + * @param array $context + * + * @return array + */ + public function extractNested(object $object, array $context): array + { + $fields = $this->filter->filter($this->mapper->metadata, $context); + + if ($fields === null) { + return $this->mapper->extractNested($object, $context); + } + + return $this->mapper->extractFiltered($object, $context, $fields, true); + } + + /** + * Removes the fields of the properties the filter leaves out, also under their aliases, so they are treated as + * missing. Unknown fields don't matter, they are not read anyway. + * + * @param ClassMetadata $metadata + * @param array $data + * @param array $fields the properties to map by field name + * + * @return array + */ + private static function withoutFilteredFields(ClassMetadata $metadata, array $data, array $fields): array + { + foreach ($metadata->properties as $property) { + if (isset($fields[$property->fieldName])) { + continue; + } + + unset($data[$property->fieldName]); + + foreach ($property->aliases as $alias) { + unset($data[$alias]); + } + } + + return $data; + } +} diff --git a/src/Extension/Generated/GeneratedMapper.php b/src/Extension/Generated/GeneratedMapper.php index 6af6e33..52a4932 100644 --- a/src/Extension/Generated/GeneratedMapper.php +++ b/src/Extension/Generated/GeneratedMapper.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Mapper\CallStack; use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Metadata\PropertyMetadata; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\Normalizer; @@ -22,7 +23,7 @@ * * The mappers are initialized on the first use: only then the hydrator has built the mappers of the nested classes. * Nested objects are mapped with the mapper of their class right away, for calls of the hydrator which created this - * mapper. + * mapper. If the hydrator has a property filter, the mapper is wrapped in a {@see FilteredMapper}. * * The members are public, because the generated code also runs in closures bound to the scope of the mapped class to * access its private properties. @@ -84,6 +85,17 @@ final public function recursive(): bool return $this->recursive = $this->mayReachItself(); } + /** + * Only reads the properties the property filter keeps, see {@see FilteredMapper}. + * + * @param array $context + * @param array $fields the properties to extract by field name + * @param bool $inline nested objects may be mapped with the mapper of their class right away + * + * @return array + */ + abstract public function extractFiltered(object $object, array $context, array $fields, bool $inline): array; + /** Resolves the normalizers and the mappers of the nested classes. */ abstract protected function initialize(): void; @@ -104,8 +116,8 @@ final protected function promotedDefault(string $property): ReflectionParameter /** * The mapper the hydrator runs for the nested class, if the objects of the property can be mapped with it right * away: the property still has the object normalizer of the class the code was generated for and the nested - * class has no class normalizer. A generated mapper is called directly, any other chain, for example with - * middlewares, through a {@see ChainNestedMapper}. + * class has no class normalizer. A generated mapper is called directly, also inside a {@see FilteredMapper}, any + * other chain, for example with middlewares, through a {@see ChainNestedMapper}. * * @param class-string $class */ @@ -129,7 +141,7 @@ final protected function nested(Normalizer $normalizer, string $class): NestedMa return null; } - if ($mapper === null || $mapper instanceof self) { + if ($mapper === null || $mapper instanceof self || $mapper instanceof FilteredMapper) { return $mapper; } @@ -139,6 +151,10 @@ final protected function nested(Normalizer $normalizer, string $class): NestedMa /** Initializes a nested generated mapper, the nested class may lead back to this one. */ final protected static function initNested(NestedMapper|null $mapper): void { + if ($mapper instanceof FilteredMapper) { + $mapper = $mapper->mapper; + } + if (!$mapper instanceof self) { return; } @@ -149,6 +165,10 @@ final protected static function initNested(NestedMapper|null $mapper): void /** Whether objects extracted with the mapper can lead back to the same object. */ final protected static function reaches(NestedMapper $mapper): bool { + if ($mapper instanceof FilteredMapper) { + $mapper = $mapper->mapper; + } + // the middlewares and the lazy proxy of a chain are not known, they might return !$mapper instanceof self || $mapper->recursive(); } diff --git a/src/Extension/Generated/GeneratedMapperFactory.php b/src/Extension/Generated/GeneratedMapperFactory.php index f41da6a..b428b73 100644 --- a/src/Extension/Generated/GeneratedMapperFactory.php +++ b/src/Extension/Generated/GeneratedMapperFactory.php @@ -58,6 +58,12 @@ public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper $mapper = new $fqcn($metadata, $hydrator, $this->callStacks[$hydrator] ??= new CallStack()); assert($mapper instanceof GeneratedMapper); - return $mapper; + $filter = $hydrator->propertyFilter(); + + if ($filter === null) { + return $mapper; + } + + return new FilteredMapper($mapper, $filter); } } diff --git a/src/Extension/Generated/MapperFiles.php b/src/Extension/Generated/MapperFiles.php index 6fc6ac0..a533c82 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 = 12; + public const VERSION = 13; public function __construct( public readonly string $cachePath, diff --git a/src/Extension/Generated/Templates.php b/src/Extension/Generated/Templates.php index e4b3bd2..122362b 100644 --- a/src/Extension/Generated/Templates.php +++ b/src/Extension/Generated/Templates.php @@ -127,6 +127,11 @@ public function extractNested(object $object, array $context): array {{extractNested}} } + public function extractFiltered(object $object, array $context, array $fields, bool $inline): array + { + {{extractFiltered}} + } + {{populate}} protected function initialize(): void @@ -181,11 +186,7 @@ private function populate(object $object, array $data, array $context): object $this->callStack->objects[$id] = \{{class}}::class; try { - {{pre}} - - return [ - {{fields}} - ]; + {{body}} } finally { unset($this->callStack->objects[$id]); } @@ -213,6 +214,34 @@ private function populate(object $object, array $data, array $context): object if (!\array_key_exists({{field}}, $data) && \array_key_exists({{alias}}, $data)) { $data[{{field}}] = $data[{{alias}}]; } +PHP; + + public const EXTRACT_FILTERED = <<<'PHP' +$data = []; + +{{pre}} + +{{fields}} + +return $data; +PHP; + + public const EXTRACT_FILTERED_HELPER = <<<'PHP' +$this->fp{{slot}} = Closure::bind(function (object $object, array $context, array $fields, bool $inline): array { + $data = []; + + {{body}} + + return $data; +}, $this, \{{scope}}::class); +PHP; + + public const EXTRACT_FILTERED_FIELD = <<<'PHP' +if (isset($fields[{{field}}])) { + {{code}} + + $data[{{field}}] = {{value}}; +} PHP; public const ASSIGN = <<<'PHP' diff --git a/src/Extension/Groups/Attribute/Groups.php b/src/Extension/Groups/Attribute/Groups.php new file mode 100644 index 0000000..40cd6f8 --- /dev/null +++ b/src/Extension/Groups/Attribute/Groups.php @@ -0,0 +1,23 @@ + */ + public readonly array $groups; + + /** @param string|array $groups */ + public function __construct(string|array $groups) + { + $this->groups = is_string($groups) ? [$groups] : array_values($groups); + } +} diff --git a/src/Extension/Groups/GroupsExtension.php b/src/Extension/Groups/GroupsExtension.php new file mode 100644 index 0000000..e36ecdc --- /dev/null +++ b/src/Extension/Groups/GroupsExtension.php @@ -0,0 +1,26 @@ +addMetadataEnricher(new GroupsMetadataEnricher()); + $builder->addPropertyFilter(new GroupsPropertyFilter()); + } +} diff --git a/src/Extension/Groups/GroupsMetadataEnricher.php b/src/Extension/Groups/GroupsMetadataEnricher.php new file mode 100644 index 0000000..b5cc4b5 --- /dev/null +++ b/src/Extension/Groups/GroupsMetadataEnricher.php @@ -0,0 +1,35 @@ +properties as $property) { + $attributeReflectionList = $property->reflection->getAttributes(Groups::class); + + if ($attributeReflectionList === []) { + continue; + } + + $property->extras[Groups::class] = $attributeReflectionList[0]->newInstance()->groups; + $hasGroups = true; + } + + if (!$hasGroups) { + return; + } + + // ignored groups can not match a class without any groups, the filter skips it + $classMetadata->extras[Groups::class] = true; + } +} diff --git a/src/Extension/Groups/GroupsPropertyFilter.php b/src/Extension/Groups/GroupsPropertyFilter.php new file mode 100644 index 0000000..5505172 --- /dev/null +++ b/src/Extension/Groups/GroupsPropertyFilter.php @@ -0,0 +1,147 @@ +|null>> class => key of the groups => selection */ + private array $selections = []; + + /** + * @param ClassMetadata $metadata + * @param array $context + * + * @return array|null + * + * @template T of object + */ + public function filter(ClassMetadata $metadata, array $context): array|null + { + if (!isset($context[GroupsExtension::GROUPS]) && !isset($context[GroupsExtension::IGNORED_GROUPS])) { + return null; + } + + $groups = $context[GroupsExtension::GROUPS] ?? null; + + // ignored groups can't match a class without any groups + if ($groups === null && !isset($metadata->extras[Groups::class])) { + return null; + } + + $ignoredGroups = $context[GroupsExtension::IGNORED_GROUPS] ?? null; + $key = $this->key($groups) . "\0" . $this->key($ignoredGroups); + + $selections = $this->selections[$metadata->className] ?? []; + + if (array_key_exists($key, $selections)) { + return $selections[$key]; + } + + return $this->selections[$metadata->className][$key] = $this->select( + $metadata, + $this->normalize($groups), + $this->normalize($ignoredGroups), + ); + } + + /** + * @param ClassMetadata $metadata + * @param list|null $groups + * @param list|null $ignoredGroups + * + * @return array|null null if no property is left out + * + * @template T of object + */ + private function select(ClassMetadata $metadata, array|null $groups, array|null $ignoredGroups): array|null + { + if ($groups !== null && in_array(GroupsExtension::ALL, $groups, true)) { + $groups = null; + } + + $properties = []; + $excluded = false; + + foreach ($metadata->properties as $propertyMetadata) { + /** @var list $propertyGroups */ + $propertyGroups = $propertyMetadata->extras[Groups::class] ?? []; + + if ( + ($groups !== null && array_intersect($propertyGroups, $groups) === []) + || ($ignoredGroups !== null && array_intersect($propertyGroups, $ignoredGroups) !== []) + ) { + $excluded = true; + + continue; + } + + $properties[$propertyMetadata->fieldName] = $propertyMetadata; + } + + return $excluded ? $properties : null; + } + + /** + * Builds a cheap cache key from the raw context value, values with the same key normalize to the same groups. + */ + private function key(mixed $groups): string + { + if (is_string($groups)) { + return "\1" . $groups; + } + + if (!is_array($groups)) { + return ''; + } + + $key = ''; + + foreach ($groups as $group) { + if (!is_string($group)) { + continue; + } + + $key .= "\1" . $group; + } + + return $key; + } + + /** @return list|null */ + private function normalize(mixed $groups): array|null + { + if (is_string($groups)) { + return [$groups]; + } + + if (!is_array($groups)) { + return null; + } + + $result = []; + + foreach ($groups as $group) { + if (!is_string($group)) { + continue; + } + + $result[] = $group; + } + + return $result === [] ? null : $result; + } +} diff --git a/src/Mapper/ChainPropertyFilter.php b/src/Mapper/ChainPropertyFilter.php new file mode 100644 index 0000000..86c9d9d --- /dev/null +++ b/src/Mapper/ChainPropertyFilter.php @@ -0,0 +1,49 @@ + $filters */ + public function __construct( + private readonly array $filters, + ) { + } + + /** + * @param ClassMetadata $metadata + * @param array $context + * + * @return array|null + * + * @template T of object + */ + public function filter(ClassMetadata $metadata, array $context): array|null + { + $result = null; + + foreach ($this->filters as $filter) { + $fields = $filter->filter($metadata, $context); + + if ($fields === null) { + continue; + } + + $result = $result === null ? $fields : array_intersect_key($result, $fields); + } + + return $result; + } +} diff --git a/src/Mapper/PropertyFilter.php b/src/Mapper/PropertyFilter.php new file mode 100644 index 0000000..230ff39 --- /dev/null +++ b/src/Mapper/PropertyFilter.php @@ -0,0 +1,32 @@ + $metadata + * @param array $context + * + * @return array|null the properties to map by field name, in the order of the metadata, or null to map every property + * + * @template T of object + */ + public function filter(ClassMetadata $metadata, array $context): array|null; +} diff --git a/src/Mapper/ReflectionMapper.php b/src/Mapper/ReflectionMapper.php index 0468034..e44fb17 100644 --- a/src/Mapper/ReflectionMapper.php +++ b/src/Mapper/ReflectionMapper.php @@ -33,6 +33,8 @@ * their class right away, without the detour through the normalizer and the hydrator. This only happens for calls of * the hydrator which created this mapper: a hydrator which wraps it has to see the nested objects. * + * The {@see PropertyFilter} of the hydrator decides which properties are mapped for a call. + * * @template T of object * @implements Mapper */ @@ -44,12 +46,15 @@ final class ReflectionMapper implements Mapper /** @var array|null> property name => mapper of the nested class */ private array $nestedMappers = []; + private readonly PropertyFilter|null $filter; + /** @param ClassMetadata $metadata */ public function __construct( private readonly ClassMetadata $metadata, private readonly StackHydrator|null $hydrator = null, private readonly CallStack $callStack = new CallStack(), ) { + $this->filter = $hydrator?->propertyFilter(); } /** @@ -85,6 +90,14 @@ private function hydrateProperties(object $object, array $data, array $context, $metadata = $this->metadata; $constructorParameters = null; + if ($this->filter !== null) { + $fields = $this->filter->filter($metadata, $context); + + if ($fields !== null) { + $data = self::withoutFilteredFields($metadata, $data, $fields); + } + } + foreach ($metadata->properties as $propertyMetadata) { if (!array_key_exists($propertyMetadata->fieldName, $data)) { $alias = self::alias($propertyMetadata, $data); @@ -150,6 +163,33 @@ private function hydrateProperties(object $object, array $data, array $context, return $object; } + /** + * Removes the fields of the properties the filter leaves out, also under their aliases, so they are treated as + * missing. Unknown fields don't matter, they are not read anyway. + * + * @param ClassMetadata $metadata + * @param array $data + * @param array $fields the properties to map by field name + * + * @return array + */ + private static function withoutFilteredFields(ClassMetadata $metadata, array $data, array $fields): array + { + foreach ($metadata->properties as $property) { + if (isset($fields[$property->fieldName])) { + continue; + } + + unset($data[$property->fieldName]); + + foreach ($property->aliases as $alias) { + unset($data[$alias]); + } + } + + return $data; + } + /** * The first alias of the property which is in the data. * @@ -186,6 +226,12 @@ public function extract(object $object, array $context): array */ private function extractProperties(object $object, array $context, array $nested): array { + $properties = $this->metadata->properties; + + if ($this->filter !== null) { + $properties = $this->filter->filter($this->metadata, $context) ?? $properties; + } + $objectId = spl_object_id($object); if (array_key_exists($objectId, $this->callStack->objects)) { @@ -200,7 +246,7 @@ private function extractProperties(object $object, array $context, array $nested try { $data = []; - foreach ($this->metadata->properties as $propertyMetadata) { + foreach ($properties as $propertyMetadata) { if ($propertyMetadata->normalizer) { try { /** @psalm-suppress MixedAssignment */ diff --git a/src/StackHydrator.php b/src/StackHydrator.php index eff8056..6611780 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -5,9 +5,11 @@ namespace Patchlevel\Hydrator; use Patchlevel\Hydrator\Mapper\CallStack; +use Patchlevel\Hydrator\Mapper\ChainPropertyFilter; use Patchlevel\Hydrator\Mapper\LazyMapper; use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Mapper\MapperFactory; +use Patchlevel\Hydrator\Mapper\PropertyFilter; use Patchlevel\Hydrator\Mapper\ReflectionMapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; @@ -21,6 +23,7 @@ use function array_key_exists; use function array_reverse; use function assert; +use function count; use function is_array; use const PHP_VERSION_ID; @@ -40,6 +43,8 @@ final class StackHydrator implements Hydrator private readonly CallStack $callStack; + private readonly PropertyFilter|null $propertyFilter; + /** * The context of calls without a context, built once: adding the hydrator to an empty context would allocate a new * array on every call. @@ -49,17 +54,24 @@ final class StackHydrator implements Hydrator private readonly array $context; /** - * @param list $middlewares wrap the mapper of every class, in this order - * @param list $mapperFactories asked in this order for the mapper of a class, the first mapper wins + * @param list $middlewares wrap the mapper of every class, in this order + * @param list $mapperFactories asked in this order for the mapper of a class, the first mapper wins + * @param list $propertyFilters a property is only mapped if every filter keeps it */ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), private readonly array $middlewares = [], private readonly bool $defaultLazy = false, private readonly array $mapperFactories = [], + array $propertyFilters = [], ) { $this->callStack = new CallStack(); $this->context = [self::HYDRATOR => $this]; + $this->propertyFilter = match (count($propertyFilters)) { + 0 => null, + 1 => $propertyFilters[0], + default => new ChainPropertyFilter($propertyFilters), + }; } /** @@ -166,6 +178,15 @@ public function mapper(string $class): Mapper|null return $this->mappers[$class] = $mapper; } + /** + * The filter the mappers of this hydrator apply to the properties of every object, null if there is none. Mappers + * of a {@see MapperFactory} have to apply it as well. + */ + public function propertyFilter(): PropertyFilter|null + { + return $this->propertyFilter; + } + /** * @param class-string $class * diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php index 6157d4c..4a5d31d 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -8,6 +8,7 @@ use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Mapper\MapperFactory; +use Patchlevel\Hydrator\Mapper\PropertyFilter; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\EnrichingMetadataFactory; use Patchlevel\Hydrator\Metadata\MetadataEnricher; @@ -37,6 +38,9 @@ final class StackHydratorBuilder /** @var array> */ private array $mapperFactories = []; + /** @var list */ + private array $propertyFilters = []; + private CacheItemPoolInterface|CacheInterface|null $cache = null; /** @return $this */ @@ -80,6 +84,18 @@ public function addMapperFactory(MapperFactory $factory, int $priority = 0): sta return $this; } + /** + * Filters decide which properties are mapped for a call, a property is only mapped if every filter keeps it. + * + * @return $this + */ + public function addPropertyFilter(PropertyFilter $filter): static + { + $this->propertyFilters[] = $filter; + + return $this; + } + public function enableDefaultLazy(bool $lazy = true): static { $this->defaultLazy = $lazy; @@ -108,6 +124,7 @@ public function build(): StackHydrator $this->middlewares(), $this->defaultLazy, $this->mapperFactories(), + $this->propertyFilters, ); } @@ -164,6 +181,12 @@ public function mapperFactories(): array return array_merge(...$this->mapperFactories); } + /** @return list */ + public function propertyFilters(): array + { + return $this->propertyFilters; + } + /** @return list */ public function metadataEnrichers(): array { diff --git a/tests/Architecture/NoCodeGenerationAtRuntimeTest.php b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php index 626fce6..d6d8114 100644 --- a/tests/Architecture/NoCodeGenerationAtRuntimeTest.php +++ b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php @@ -8,6 +8,7 @@ use Patchlevel\Hydrator\Extension\Generated\ClassPlan; use Patchlevel\Hydrator\Extension\Generated\ClassPlanner; use Patchlevel\Hydrator\Extension\Generated\CodeEmitter; +use Patchlevel\Hydrator\Extension\Generated\FilteredMapper; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapper; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperFactory; @@ -29,6 +30,7 @@ public function testRuntimeDoesNotDependOnTheGenerator(): Rule Selector::classname(GeneratedMapperFactory::class), Selector::classname(GeneratedMapper::class), Selector::classname(ChainNestedMapper::class), + Selector::classname(FilteredMapper::class), ) ->shouldNotDependOn() ->classes( diff --git a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php index 138fcfe..5ddd213 100644 --- a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php +++ b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php @@ -16,6 +16,7 @@ use Patchlevel\Hydrator\Extension\Generated\ClassPlan; use Patchlevel\Hydrator\Extension\Generated\ClassPlanner; use Patchlevel\Hydrator\Extension\Generated\CodeEmitter; +use Patchlevel\Hydrator\Extension\Generated\FilteredMapper; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapper; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension; use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperFactory; @@ -48,6 +49,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextPropertyFilter; use Patchlevel\Hydrator\Tests\Unit\Fixture\CountingMiddleware; use Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; @@ -61,6 +63,7 @@ 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\PrivateParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer; @@ -100,6 +103,7 @@ #[CoversClass(MapperGenerator::class)] #[CoversClass(GeneratedMapperWarmer::class)] #[CoversClass(GeneratedMapper::class)] +#[CoversClass(FilteredMapper::class)] #[CoversClass(MapperFiles::class)] #[CoversClass(ClassPlanner::class)] #[CoversClass(ClassPlan::class)] @@ -1007,7 +1011,7 @@ private static function inlined(StackHydrator $hydrator, string $class): array return $flags; } - /** The generated mapper of the class, unwrapped from the middlewares and the lazy proxy. */ + /** The generated mapper of the class, unwrapped from the middlewares, the lazy proxy and the property filter. */ private static function generated(StackHydrator $hydrator, string $class): GeneratedMapper { $mapper = self::mappers($hydrator)[$class] ?? null; @@ -1016,6 +1020,10 @@ private static function generated(StackHydrator $hydrator, string $class): Gener $mapper = (new ReflectionProperty($mapper, $mapper instanceof LazyMapper ? 'mapper' : 'next'))->getValue($mapper); } + if ($mapper instanceof FilteredMapper) { + $mapper = $mapper->mapper; + } + self::assertInstanceOf(GeneratedMapper::class, $mapper); return $mapper; @@ -1165,6 +1173,208 @@ public function testNestedObjectsRespectOtherMiddlewares(): void ); } + public function testExtractWithPropertyFilter(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertSame( + ['email' => 'info@patchlevel.de'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['email']]]), + ); + self::assertSame( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [Skill::class => []]]), + ); + } + + public function testExtractWithPropertyFilterDoesNotReadFilteredProperties(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $event = (new ReflectionClass(ProfileCreated::class))->newInstanceWithoutConstructor(); + $event->profileId = ProfileId::fromString('1'); + + self::assertSame( + ['profileId' => '1'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]]), + ); + } + + public function testExtractWithPropertyFilterOfNestedObjects(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $event = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + self::assertSame( + ['event' => ['profileId' => '1']], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]]), + ); + self::assertSame(['i0' => true], self::inlined($hydrator, ProfileCreatedWrapper::class)); + } + + public function testExtractWithPropertyFilterOfNestedLists(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $dto = new NestedLifecycleDto(new LifecycleFixture('a'), [new LifecycleFixture('b'), new LifecycleFixture('c')]); + $context = [ + ContextPropertyFilter::FIELDS => [ + NestedLifecycleDto::class => ['items'], + LifecycleFixture::class => [], + ], + ]; + + self::assertSame(['items' => [[], []]], $hydrator->extract($dto, $context)); + self::assertSame(['i0' => true, 'i1' => true], self::inlined($hydrator, NestedLifecycleDto::class)); + } + + public function testExtractWithPropertyFilterBreaksCircularReference(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $dto1 = new Circle1Dto(); + $dto2 = new Circle2Dto(); + $dto3 = new Circle3Dto(); + + $dto1->to = $dto2; + $dto2->to = $dto3; + $dto3->to = $dto1; + + self::assertSame( + ['to' => ['to' => []]], + $hydrator->extract($dto1, [ContextPropertyFilter::FIELDS => [Circle3Dto::class => []]]), + ); + } + + public function testExtractWithPropertyFilterDetectsCircularReference(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $dto1 = new Circle1Dto(); + $dto2 = new Circle2Dto(); + $dto3 = new Circle3Dto(); + + $dto1->to = $dto2; + $dto2->to = $dto3; + $dto3->to = $dto1; + + $this->expectException(CircularReference::class); + + $hydrator->extract($dto1, [ContextPropertyFilter::FIELDS => [Circle3Dto::class => ['to']]]); + } + + public function testExtractWithPropertyFilterOfPrivateProperties(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $dto = new PrivateChildDto(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'), 'note', 'foo'); + + // the order of the metadata is kept, also for the private properties of the parent + self::assertSame( + ['profileId' => '1', 'note' => 'note'], + $hydrator->extract($dto, [ContextPropertyFilter::FIELDS => [PrivateChildDto::class => ['note', 'profileId']]]), + ); + self::assertSame( + ['name' => 'foo', 'email' => 'info@patchlevel.de'], + $hydrator->extract($dto, [ContextPropertyFilter::FIELDS => [PrivateChildDto::class => ['email', 'name']]]), + ); + } + + public function testHydrateWithPropertyFilter(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $object = $hydrator->hydrate( + DefaultDto::class, + ['name' => 'test', 'email' => 'test@patchlevel.de', 'admin' => false], + [ContextPropertyFilter::FIELDS => [DefaultDto::class => ['name']]], + ); + + // filtered promoted properties fall back to their default, all others are not touched + self::assertEquals(new DefaultDto('test'), $object); + } + + public function testHydrateWithPropertyFilterAndAlias(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $object = $hydrator->hydrate( + AliasDto::class, + ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel'], + [ContextPropertyFilter::FIELDS => [AliasDto::class => ['email']]], + ); + + // kept properties are read from their aliases, the aliases of filtered properties are ignored + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de')), $object); + + $object = $hydrator->hydrate( + AliasDto::class, + ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel'], + [ContextPropertyFilter::FIELDS => [AliasDto::class => ['email', 'name']]], + ); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel'), $object); + } + + public function testHydrateWithPropertyFilterAndObjectToPopulate(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + $dto = new DefaultDto('old', Email::fromString('old@patchlevel.de')); + $dto->admin = false; + + $object = $hydrator->hydrate( + DefaultDto::class, + ['name' => 'new', 'email' => 'new@patchlevel.de', 'admin' => true], + [ + ContextPropertyFilter::FIELDS => [DefaultDto::class => ['name']], + Hydrator::OBJECT_TO_POPULATE => $dto, + ], + ); + + // the filtered properties of an existing object are not touched, also not set to their 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 testHydrateWithPropertyFilterOfNestedObjects(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $object = $hydrator->hydrate( + ProfileCreatedWrapper::class, + ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']], + [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]], + ); + + self::assertEquals(ProfileId::fromString('1'), $object->profileCreated->profileId); + self::assertFalse(isset($object->profileCreated->email)); + self::assertSame(['i0' => true], self::inlined($hydrator, ProfileCreatedWrapper::class)); + } + + public function testHydrateWithPropertyFilterOfPrivateProperties(): void + { + $hydrator = $this->builder()->addPropertyFilter(new ContextPropertyFilter())->build(); + + $object = $hydrator->hydrate( + PrivateChildDto::class, + ['profileId' => '1', 'name' => 'foo', 'email' => 'info@patchlevel.de', 'note' => 'note'], + [ContextPropertyFilter::FIELDS => [PrivateChildDto::class => ['name', 'note']]], + ); + + self::assertFalse((new ReflectionProperty(PrivateChildDto::class, 'profileId'))->isInitialized($object)); + self::assertSame('foo', $object->name()); + self::assertFalse((new ReflectionProperty(PrivateParentDto::class, 'email'))->isInitialized($object)); + self::assertSame('note', $object->note()); + } + public function testOuterHydratorIsUsedForNestedObjects(): void { $outer = new class ($this->hydrator) implements Hydrator { diff --git a/tests/Unit/Extension/Generated/MapperGeneratorTest.php b/tests/Unit/Extension/Generated/MapperGeneratorTest.php index 6bff309..44a9e01 100644 --- a/tests/Unit/Extension/Generated/MapperGeneratorTest.php +++ b/tests/Unit/Extension/Generated/MapperGeneratorTest.php @@ -84,6 +84,17 @@ public function testNestedClasses(): void self::assertSame([LifecycleFixture::class], $code->nested); } + public function testClassesWithoutNormalizersDoNotTrackCircularReferences(): void + { + $code = (new MapperGenerator())->generate( + (new AttributeMetadataFactory())->metadata(LifecycleFixture::class), + 'Foo\\Bar', + )->code; + + self::assertStringNotContainsString('$this->tracked', $code); + self::assertStringContainsString('public function extractFiltered(', $code); + } + /** * The snapshots make every change of the generated code visible in the diff of a pull request. * Run "make snapshot" after an intended change of the generator. diff --git a/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php index f6c362f..08aed22 100644 --- a/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class; + + try { + $data = []; + + if (isset($fields['email'])) { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['email'])) { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { if (!\array_key_exists('email', $data) && \array_key_exists('mail', $data)) { diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php index f7991a1..c7c5f90 100644 --- a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class; + + try { + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('to'); diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php index 6671bbd..fa5fa45 100644 --- a/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class; + + try { + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('to'); diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php index 7db3c06..73eab7c 100644 --- a/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class; + + try { + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['to'])) { + try { + $value = $object->to; + $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e); + } + + $data['to'] = $v_to; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('to'); diff --git a/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php index b2727a9..6b36fba 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class; + + try { + $data = []; + + if (isset($fields['value'])) { + try { + $v_value = $this->n0->normalize($object->value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e); + } + + $data['value'] = $v_value; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['value'])) { + try { + $v_value = $this->n0->normalize($object->value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e); + } + + $data['value'] = $v_value; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('value'); diff --git a/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php index 440aeba..5e57bc7 100644 --- a/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class; + + try { + $data = []; + + if (isset($fields['admin'])) { + $data['admin'] = $object->admin; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['email'])) { + try { + $value = $object->email; + $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['admin'])) { + $data['admin'] = $object->admin; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['email'])) { + try { + $value = $object->email; + $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php index acfbf13..6cdfbf6 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class; + + try { + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['dateTimeImmutable'])) { + try { + $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e); + } + + $data['dateTimeImmutable'] = $v_dateTimeImmutable; + } + + if (isset($fields['dateTime'])) { + try { + $v_dateTime = $this->n2->normalize($object->dateTime, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e); + } + + $data['dateTime'] = $v_dateTime; + } + + if (isset($fields['dateTimeZone'])) { + try { + $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e); + } + + $data['dateTimeZone'] = $v_dateTimeZone; + } + + if (isset($fields['array'])) { + $data['array'] = $object->array; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['dateTimeImmutable'])) { + try { + $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e); + } + + $data['dateTimeImmutable'] = $v_dateTimeImmutable; + } + + if (isset($fields['dateTime'])) { + try { + $v_dateTime = $this->n2->normalize($object->dateTime, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e); + } + + $data['dateTime'] = $v_dateTime; + } + + if (isset($fields['dateTimeZone'])) { + try { + $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e); + } + + $data['dateTimeZone'] = $v_dateTimeZone; + } + + if (isset($fields['array'])) { + $data['array'] = $object->array; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('status'); diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php index c8a57d9..f33d9b9 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class; + + try { + $data = []; + + if (isset($fields['defaultArray'])) { + try { + $v_defaultArray = $this->n0->normalize($object->defaultArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e); + } + + $data['defaultArray'] = $v_defaultArray; + } + + if (isset($fields['listArray'])) { + try { + $v_listArray = $this->n1->normalize($object->listArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e); + } + + $data['listArray'] = $v_listArray; + } + + if (isset($fields['iterableArray'])) { + try { + $v_iterableArray = $this->n2->normalize($object->iterableArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e); + } + + $data['iterableArray'] = $v_iterableArray; + } + + if (isset($fields['hashMap'])) { + try { + $v_hashMap = $this->n3->normalize($object->hashMap, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e); + } + + $data['hashMap'] = $v_hashMap; + } + + if (isset($fields['nested'])) { + try { + $v_nested = $this->n4->normalize($object->nested, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e); + } + + $data['nested'] = $v_nested; + } + + if (isset($fields['jsonArray'])) { + $data['jsonArray'] = $object->jsonArray; + } + + if (isset($fields['shapeArray'])) { + try { + $v_shapeArray = $this->n5->normalize($object->shapeArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e); + } + + $data['shapeArray'] = $v_shapeArray; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['defaultArray'])) { + try { + $v_defaultArray = $this->n0->normalize($object->defaultArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e); + } + + $data['defaultArray'] = $v_defaultArray; + } + + if (isset($fields['listArray'])) { + try { + $v_listArray = $this->n1->normalize($object->listArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e); + } + + $data['listArray'] = $v_listArray; + } + + if (isset($fields['iterableArray'])) { + try { + $v_iterableArray = $this->n2->normalize($object->iterableArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e); + } + + $data['iterableArray'] = $v_iterableArray; + } + + if (isset($fields['hashMap'])) { + try { + $v_hashMap = $this->n3->normalize($object->hashMap, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e); + } + + $data['hashMap'] = $v_hashMap; + } + + if (isset($fields['nested'])) { + try { + $v_nested = $this->n4->normalize($object->nested, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e); + } + + $data['nested'] = $v_nested; + } + + if (isset($fields['jsonArray'])) { + $data['jsonArray'] = $object->jsonArray; + } + + if (isset($fields['shapeArray'])) { + try { + $v_shapeArray = $this->n5->normalize($object->shapeArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e); + } + + $data['shapeArray'] = $v_shapeArray; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { if (\array_key_exists('defaultArray', $data)) { diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php index f777aa6..f89db5b 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class; + + try { + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['dateTimeImmutable'])) { + try { + $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e); + } + + $data['dateTimeImmutable'] = $v_dateTimeImmutable; + } + + if (isset($fields['dateTime'])) { + try { + $v_dateTime = $this->n2->normalize($object->dateTime, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e); + } + + $data['dateTime'] = $v_dateTime; + } + + if (isset($fields['dateTimeZone'])) { + try { + $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e); + } + + $data['dateTimeZone'] = $v_dateTimeZone; + } + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n4->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['dateTimeImmutable'])) { + try { + $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e); + } + + $data['dateTimeImmutable'] = $v_dateTimeImmutable; + } + + if (isset($fields['dateTime'])) { + try { + $v_dateTime = $this->n2->normalize($object->dateTime, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e); + } + + $data['dateTime'] = $v_dateTime; + } + + if (isset($fields['dateTimeZone'])) { + try { + $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e); + } + + $data['dateTimeZone'] = $v_dateTimeZone; + } + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n4->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { if (\array_key_exists('status', $data)) { diff --git a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php index 3474d62..5e9e6ba 100644 --- a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class; + + try { + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); diff --git a/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php index 3fb3ecd..160d8b6 100644 --- a/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class; + + try { + $data = []; + + if (isset($fields['child'])) { + try { + $value = $object->child; + $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e); + } + + $data['child'] = $v_child; + } + + if (isset($fields['items'])) { + try { + $value = $object->items; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context); + } + + $v_items = $items; + } else { + $v_items = $this->n1->normalize($value, $context); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e); + } + + $data['items'] = $v_items; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['child'])) { + try { + $value = $object->child; + $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e); + } + + $data['child'] = $v_child; + } + + if (isset($fields['items'])) { + try { + $value = $object->items; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context); + } + + $v_items = $items; + } else { + $v_items = $this->n1->normalize($value, $context); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e); + } + + $data['items'] = $v_items; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; diff --git a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php index bc39ee9..3dc870b 100644 --- a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class; + + try { + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['profileCreated'])) { + try { + $value = $object->profileCreated; + $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e); + } + + $data['profileCreated'] = $v_profileCreated; + } + + if (isset($fields['defaultArray'])) { + try { + $v_defaultArray = $this->n2->normalize($object->defaultArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e); + } + + $data['defaultArray'] = $v_defaultArray; + } + + if (isset($fields['listArray'])) { + try { + $v_listArray = $this->n3->normalize($object->listArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e); + } + + $data['listArray'] = $v_listArray; + } + + if (isset($fields['iterableArray'])) { + try { + $v_iterableArray = $this->n4->normalize($object->iterableArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e); + } + + $data['iterableArray'] = $v_iterableArray; + } + + if (isset($fields['skillsHashMap'])) { + try { + $value = $object->skillsHashMap; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context); + } + + $v_skillsHashMap = $items; + } else { + $v_skillsHashMap = $this->n5->normalize($value, $context); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e); + } + + $data['skillsHashMap'] = $v_skillsHashMap; + } + + if (isset($fields['jsonArray'])) { + $data['jsonArray'] = $object->jsonArray; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['status'])) { + try { + $v_status = $this->n0->normalize($object->status, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e); + } + + $data['status'] = $v_status; + } + + if (isset($fields['profileCreated'])) { + try { + $value = $object->profileCreated; + $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e); + } + + $data['profileCreated'] = $v_profileCreated; + } + + if (isset($fields['defaultArray'])) { + try { + $v_defaultArray = $this->n2->normalize($object->defaultArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e); + } + + $data['defaultArray'] = $v_defaultArray; + } + + if (isset($fields['listArray'])) { + try { + $v_listArray = $this->n3->normalize($object->listArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e); + } + + $data['listArray'] = $v_listArray; + } + + if (isset($fields['iterableArray'])) { + try { + $v_iterableArray = $this->n4->normalize($object->iterableArray, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e); + } + + $data['iterableArray'] = $v_iterableArray; + } + + if (isset($fields['skillsHashMap'])) { + try { + $value = $object->skillsHashMap; + + if ($this->i1 && $inline && \is_array($value)) { + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context); + } + + $v_skillsHashMap = $items; + } else { + $v_skillsHashMap = $this->n5->normalize($value, $context); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e); + } + + $data['skillsHashMap'] = $v_skillsHashMap; + } + + if (isset($fields['jsonArray'])) { + $data['jsonArray'] = $object->jsonArray; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator; diff --git a/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php index 51564a2..426c6ca 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class; + + try { + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php index 282499c..bccf2ae 100644 --- a/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php @@ -1,7 +1,7 @@ e)($object, $context, $inline); } + public function extractFiltered(object $object, array $context, array $fields, bool $inline): array + { + return ($this->f)($object, $context, $fields, $inline); + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); @@ -120,6 +127,27 @@ protected function initialize(): void } return [$v_email, $object->note]; }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateParentDto::class); + $this->fp2 = Closure::bind(function (object $object, array $context, array $fields, bool $inline): array { + $data = []; + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + if (isset($fields['note'])) { + $data['note'] = $object->note; + } + + return $data; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateParentDto::class); $this->h = Closure::bind(function (object $object, array $data, array $context, bool $inline): object { if (\array_key_exists('profileId', $data)) { try { @@ -201,6 +229,84 @@ protected function initialize(): void 'note' => $v_note, ]; }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class); + $this->f = Closure::bind(function (object $object, array $context, array $fields, bool $inline): array { + if ($this->tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class; + + try { + $data = []; + + $s2 = ($this->fp2)($object, $context, $fields, $inline); + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['email'])) { + $data['email'] = $s2['email']; + } + + if (isset($fields['note'])) { + $data['note'] = $s2['note']; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + $s2 = ($this->fp2)($object, $context, $fields, $inline); + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['email'])) { + $data['email'] = $s2['email']; + } + + if (isset($fields['note'])) { + $data['note'] = $s2['note']; + } + + return $data; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class); } protected function mayReachItself(): bool diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php index ce748c5..c3838e9 100644 --- a/tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php @@ -1,7 +1,7 @@ fp3)($object, $context, $fields, $inline); + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['note'])) { + $data['note'] = $s3['note']; + } + + return $data; + } + private function populate(object $object, array $data, array $context): object { $inline = false; @@ -163,6 +181,15 @@ protected function initialize(): void $this->ep2 = Closure::bind(function (object $object, array $context, bool $inline): array { return [$object->note]; }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultParentDto::class); + $this->fp3 = Closure::bind(function (object $object, array $context, array $fields, bool $inline): array { + $data = []; + + if (isset($fields['note'])) { + $data['note'] = $object->note; + } + + return $data; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultParentDto::class); } protected function mayReachItself(): bool diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php index 6b875af..b9941f6 100644 --- a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php @@ -1,7 +1,7 @@ e)($object, $context, $inline); } + public function extractFiltered(object $object, array $context, array $fields, bool $inline): array + { + return ($this->f)($object, $context, $fields, $inline); + } + private function populate(object $object, array $data, array $context): object { $inline = false; @@ -214,6 +220,72 @@ protected function initialize(): void 'age' => $object->age, ]; }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class); + $this->f = Closure::bind(function (object $object, array $context, array $fields, bool $inline): array { + if ($this->tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class; + + try { + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['age'])) { + $data['age'] = $object->age; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['name'])) { + $data['name'] = $object->name; + } + + if (isset($fields['age'])) { + $data['age'] = $object->age; + } + + return $data; + }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class); } protected function mayReachItself(): bool diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php index 23e8c32..e57111a 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class; + + try { + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php index d78420f..2c360b4 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class; + + try { + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['valueObject'])) { + try { + $v_valueObject = $this->n1->normalize($object->valueObject, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e); + } + + $data['valueObject'] = $v_valueObject; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['profileId'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e); + } + + $data['profileId'] = $v_profileId; + } + + if (isset($fields['valueObject'])) { + try { + $v_valueObject = $this->n1->normalize($object->valueObject, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e); + } + + $data['valueObject'] = $v_valueObject; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php index ceef1cd..e81f915 100644 --- a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class; + + try { + $data = []; + + if (isset($fields['event'])) { + try { + $value = $object->profileCreated; + $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e); + } + + $data['event'] = $v_profileCreated; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['event'])) { + try { + $value = $object->profileCreated; + $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e); + } + + $data['event'] = $v_profileCreated; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileCreated'); diff --git a/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php index 2407c04..f853e3c 100644 --- a/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class; + + try { + $data = []; + + if (isset($fields['id'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['id'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['id'])) { + try { + $v_profileId = $this->n0->normalize($object->profileId, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e); + } + + $data['id'] = $v_profileId; + } + + if (isset($fields['email'])) { + try { + $v_email = $this->n1->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); diff --git a/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php index 5efffcd..d7f4238 100644 --- a/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php @@ -1,7 +1,7 @@ tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class; + + try { + $data = []; + + if (isset($fields['email'])) { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } finally { + unset($this->callStack->objects[$id]); + } + } + + $data = []; + + if (isset($fields['email'])) { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e); + } + + $data['email'] = $v_email; + } + + return $data; + } + protected function initialize(): void { $this->n0 = $this->normalizer('email'); diff --git a/tests/Unit/Extension/Groups/Fixture/AddressFixture.php b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php new file mode 100644 index 0000000..ac175ce --- /dev/null +++ b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php @@ -0,0 +1,18 @@ +metadataFactory(), self::$cachePath))->warmup([ + UserFixture::class, + ParentFixture::class, + ]); + } + + public static function tearDownAfterClass(): void + { + foreach (glob(self::$cachePath . '/*.php') ?: [] as $file) { + unlink($file); + } + + rmdir(self::$cachePath); + } + + /** @return iterable */ + public static function provideGenerated(): iterable + { + yield 'reflection' => [false]; + yield 'generated' => [true]; + } + + #[DataProvider('provideGenerated')] + public function testMappers(bool $generated): void + { + $hydrator = self::hydrator($generated); + + foreach ([UserFixture::class, AddressFixture::class, ParentFixture::class, ChildFixture::class] as $class) { + $mapper = $hydrator->mapper($class); + + if (!$generated) { + self::assertInstanceOf(ReflectionMapper::class, $mapper); + + continue; + } + + self::assertInstanceOf(FilteredMapper::class, $mapper); + } + } + + #[DataProvider('provideGenerated')] + public function testExtractWithoutGroups(bool $generated): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + self::hydrator($generated)->extract($this->user()), + ); + } + + #[DataProvider('provideGenerated')] + public function testExtractWithGroup(bool $generated): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'address' => ['city' => 'Berlin'], + ], + self::hydrator($generated)->extract($this->user(), [GroupsExtension::GROUPS => 'public']), + ); + } + + #[DataProvider('provideGenerated')] + public function testExtractWithMultipleGroups(bool $generated): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + ], + self::hydrator($generated)->extract($this->user(), [GroupsExtension::GROUPS => ['public', 'admin']]), + ); + } + + #[DataProvider('provideGenerated')] + public function testExtractWithAllGroup(bool $generated): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + self::hydrator($generated)->extract($this->user(), [GroupsExtension::GROUPS => GroupsExtension::ALL]), + ); + } + + #[DataProvider('provideGenerated')] + public function testExtractWithIgnoredGroup(bool $generated): void + { + self::assertSame( + [ + 'name' => 'John', + 'internal' => 'secret', + ], + self::hydrator($generated)->extract($this->user(), [GroupsExtension::IGNORED_GROUPS => 'admin']), + ); + } + + #[DataProvider('provideGenerated')] + public function testHydrateWithGroup(bool $generated): void + { + $user = self::hydrator($generated)->hydrate( + UserFixture::class, + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + [GroupsExtension::GROUPS => ['public']], + ); + + self::assertSame('1', $user->id); + self::assertSame('John', $user->name); + self::assertSame('Berlin', $user->address->city); + self::assertSame('default', $user->internal); + self::assertFalse(isset($user->email)); + self::assertFalse(isset($user->address->street)); + } + + #[DataProvider('provideGenerated')] + public function testHydrateWithObjectToPopulate(bool $generated): void + { + $user = $this->user(); + + $result = self::hydrator($generated)->hydrate( + UserFixture::class, + ['name' => 'Jane', 'email' => 'jane@example.com'], + [GroupsExtension::GROUPS => 'admin', Hydrator::OBJECT_TO_POPULATE => $user], + ); + + self::assertSame($user, $result); + self::assertSame('1', $user->id); + self::assertSame('John', $user->name); + self::assertSame('jane@example.com', $user->email); + self::assertSame('Berlin', $user->address->city); + self::assertSame('secret', $user->internal); + } + + #[DataProvider('provideGenerated')] + public function testExtractHydratedObjectWithTheSameGroup(bool $generated): void + { + $hydrator = self::hydrator($generated); + $data = [ + 'id' => '1', + 'name' => 'John', + 'address' => ['city' => 'Berlin'], + ]; + + // the properties of other groups are not initialized and must not be read + $user = $hydrator->hydrate(UserFixture::class, $data, [GroupsExtension::GROUPS => 'public']); + + self::assertSame($data, $hydrator->extract($user, [GroupsExtension::GROUPS => 'public'])); + } + + #[DataProvider('provideGenerated')] + public function testGroupsBreakCircularReference(bool $generated): void + { + $hydrator = self::hydrator($generated); + + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + self::assertSame( + ['name' => 'parent', 'child' => ['name' => 'child']], + $hydrator->extract($parent, [GroupsExtension::GROUPS => 'parent']), + ); + + self::assertSame( + ['name' => 'child', 'parent' => ['name' => 'parent']], + $hydrator->extract($parent->child, [GroupsExtension::GROUPS => 'child']), + ); + } + + #[DataProvider('provideGenerated')] + public function testCircularReferenceWithoutGroups(bool $generated): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + $this->expectException(CircularReference::class); + + self::hydrator($generated)->extract($parent); + } + + #[DataProvider('provideGenerated')] + public function testCircularReferenceWithAllGroup(bool $generated): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + $this->expectException(CircularReference::class); + + self::hydrator($generated)->extract($parent, [GroupsExtension::GROUPS => GroupsExtension::ALL]); + } + + private static function hydrator(bool $generated): StackHydrator + { + return self::builder($generated)->build(); + } + + private static function builder(bool $generated): StackHydratorBuilder + { + $builder = (new StackHydratorBuilder())->useExtension(new GroupsExtension()); + + if ($generated) { + $builder->useExtension(new GeneratedMapperExtension(self::$cachePath)); + } + + return $builder; + } + + private function user(): UserFixture + { + return new UserFixture( + '1', + 'John', + 'john@example.com', + new AddressFixture('Berlin', 'Main Street'), + 'secret', + ); + } +} diff --git a/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php new file mode 100644 index 0000000..1d81a6a --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php @@ -0,0 +1,62 @@ +metadata(UserFixture::class); + + (new GroupsMetadataEnricher())->enrich($metadata); + + self::assertSame(['public', 'admin'], $metadata->properties['id']->extras[Groups::class]); + self::assertSame(['public'], $metadata->properties['name']->extras[Groups::class]); + self::assertSame(['admin'], $metadata->properties['email']->extras[Groups::class]); + self::assertArrayNotHasKey(Groups::class, $metadata->properties['internal']->extras); + self::assertTrue($metadata->extras[Groups::class]); + } + + public function testPropertiesAfterAPropertyWithoutGroups(): void + { + $object = new class { + public string $name = 'foo'; + + #[Groups('public')] + public string $email = 'foo@example.com'; + }; + + $metadata = (new AttributeMetadataFactory())->metadata($object::class); + + (new GroupsMetadataEnricher())->enrich($metadata); + + self::assertArrayNotHasKey(Groups::class, $metadata->properties['name']->extras); + self::assertSame(['public'], $metadata->properties['email']->extras[Groups::class]); + self::assertTrue($metadata->extras[Groups::class]); + } + + public function testClassWithoutGroups(): void + { + $object = new class { + public string $name = 'foo'; + }; + + $metadata = (new AttributeMetadataFactory())->metadata($object::class); + + (new GroupsMetadataEnricher())->enrich($metadata); + + self::assertArrayNotHasKey(Groups::class, $metadata->extras); + self::assertArrayNotHasKey(Groups::class, $metadata->properties['name']->extras); + } +} diff --git a/tests/Unit/Extension/Groups/GroupsPropertyFilterTest.php b/tests/Unit/Extension/Groups/GroupsPropertyFilterTest.php new file mode 100644 index 0000000..d759475 --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsPropertyFilterTest.php @@ -0,0 +1,191 @@ +|null}> */ + public static function provideGroups(): iterable + { + yield 'no groups' => [null, null]; + yield 'empty list' => [[], null]; + yield 'invalid value' => [42, null]; + yield 'only invalid entries' => [[42, null], null]; + yield 'string' => ['admin', ['id', 'email', 'address']]; + yield 'list' => [['public'], ['id', 'name', 'address']]; + yield 'invalid entries are ignored' => [['public', 42], ['id', 'name', 'address']]; + yield 'unknown group' => [['unknown'], []]; + yield 'all' => [['unknown', GroupsExtension::ALL], null]; + } + + /** @param list|null $expectedFields null if no property is filtered out */ + #[DataProvider('provideGroups')] + public function testGroups(mixed $groups, array|null $expectedFields): void + { + $fields = (new GroupsPropertyFilter())->filter( + $this->metadata(UserFixture::class), + [GroupsExtension::GROUPS => $groups], + ); + + self::assertSame($expectedFields, $fields === null ? null : array_keys($fields)); + } + + /** @return iterable|null}> */ + public static function provideIgnoredGroups(): iterable + { + yield 'string' => [null, 'admin', ['name', 'internal']]; + yield 'list' => [null, ['admin', 'public'], ['internal']]; + yield 'unknown group' => [null, ['unknown'], null]; + yield 'empty list' => [null, [], null]; + yield 'with groups' => ['public', 'admin', ['name']]; + yield 'with all' => [GroupsExtension::ALL, 'admin', ['name', 'internal']]; + } + + /** @param list|null $expectedFields null if no property is filtered out */ + #[DataProvider('provideIgnoredGroups')] + public function testIgnoredGroups(mixed $groups, mixed $ignoredGroups, array|null $expectedFields): void + { + $fields = (new GroupsPropertyFilter())->filter( + $this->metadata(UserFixture::class), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + ); + + self::assertSame($expectedFields, $fields === null ? null : array_keys($fields)); + } + + /** @return iterable|null}> */ + public static function provideClassWithoutGroups(): iterable + { + yield 'groups' => ['public', null, []]; + yield 'all' => [GroupsExtension::ALL, null, null]; + yield 'ignored groups' => [null, 'admin', null]; + yield 'groups and ignored groups' => ['public', 'admin', []]; + } + + /** @param list|null $expectedFields null if no property is filtered out */ + #[DataProvider('provideClassWithoutGroups')] + public function testClassWithoutGroups(mixed $groups, mixed $ignoredGroups, array|null $expectedFields): void + { + $fields = (new GroupsPropertyFilter())->filter( + $this->metadata(NoGroupsFixture::class), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + ); + + self::assertSame($expectedFields, $fields === null ? null : array_keys($fields)); + } + + public function testFieldsPointToTheirProperties(): void + { + $metadata = $this->metadata(UserFixture::class); + + self::assertSame( + [ + 'id' => $metadata->properties['id'], + 'name' => $metadata->properties['name'], + 'address' => $metadata->properties['address'], + ], + (new GroupsPropertyFilter())->filter($metadata, [GroupsExtension::GROUPS => 'public']), + ); + } + + public function testSelectionIsReused(): void + { + $filter = new GroupsPropertyFilter(); + $metadata = $this->metadata(UserFixture::class); + + $first = $filter->filter($metadata, [GroupsExtension::GROUPS => 'public']); + + // a changed metadata only shows for selections which are not cached yet + $email = $metadata->properties['email']; + $email->extras[Groups::class] = ['public']; + + $second = $filter->filter($metadata, [GroupsExtension::GROUPS => ['public']]); + $third = $filter->filter( + $metadata, + [GroupsExtension::GROUPS => 'public', GroupsExtension::IGNORED_GROUPS => 'admin'], + ); + + self::assertSame(['id', 'name', 'address'], array_keys($first ?? [])); + self::assertSame($first, $second); + self::assertSame(['name', 'email'], array_keys($third ?? [])); + } + + public function testSelectionIsCachedPerGroups(): void + { + $filter = new GroupsPropertyFilter(); + $metadata = $this->metadata(UserFixture::class); + + $selections = [ + [[GroupsExtension::GROUPS => [42]], null], + [[GroupsExtension::GROUPS => [42, 'public']], ['id', 'name', 'address']], + [[GroupsExtension::GROUPS => 'admin'], ['id', 'email', 'address']], + [[GroupsExtension::GROUPS => ['public', 'admin']], ['id', 'name', 'email', 'address']], + [[GroupsExtension::IGNORED_GROUPS => 'admin'], ['name', 'internal']], + ]; + + foreach ($selections as [$context, $expectedFields]) { + $fields = $filter->filter($metadata, $context); + + self::assertSame($expectedFields, $fields === null ? null : array_keys($fields)); + } + } + + public function testSelectionIsCachedPerClass(): void + { + $filter = new GroupsPropertyFilter(); + + $user = $filter->filter($this->metadata(UserFixture::class), [GroupsExtension::GROUPS => 'public']); + $noGroups = $filter->filter($this->metadata(NoGroupsFixture::class), [GroupsExtension::GROUPS => 'public']); + + self::assertSame(['id', 'name', 'address'], array_keys($user ?? [])); + self::assertSame([], $noGroups); + } + + public function testEmptyStringGroupIsNotTreatedAsMissing(): void + { + $fields = (new GroupsPropertyFilter())->filter( + $this->metadata(UserFixture::class), + [GroupsExtension::GROUPS => ''], + ); + + self::assertSame([], $fields); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + private function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + (new GroupsMetadataEnricher())->enrich($metadata); + + return $metadata; + } +} diff --git a/tests/Unit/Fixture/ContextPropertyFilter.php b/tests/Unit/Fixture/ContextPropertyFilter.php new file mode 100644 index 0000000..ccf87fa --- /dev/null +++ b/tests/Unit/Fixture/ContextPropertyFilter.php @@ -0,0 +1,53 @@ + [Skill::class => ['name']]]. */ +final class ContextPropertyFilter implements PropertyFilter +{ + public const FIELDS = 'fields'; + + /** + * @param ClassMetadata $metadata + * @param array $context + * + * @return array|null + * + * @template T of object + */ + public function filter(ClassMetadata $metadata, array $context): array|null + { + $classes = $context[self::FIELDS] ?? null; + + if (!is_array($classes)) { + return null; + } + + $fields = $classes[$metadata->className] ?? null; + + if (!is_array($fields)) { + return null; + } + + $result = []; + + foreach ($metadata->properties as $property) { + if (!in_array($property->fieldName, $fields, true)) { + continue; + } + + $result[$property->fieldName] = $property; + } + + return $result; + } +} diff --git a/tests/Unit/Mapper/ChainPropertyFilterTest.php b/tests/Unit/Mapper/ChainPropertyFilterTest.php new file mode 100644 index 0000000..54cf7c4 --- /dev/null +++ b/tests/Unit/Mapper/ChainPropertyFilterTest.php @@ -0,0 +1,57 @@ +metadata(DefaultDto::class); + $filter = new ChainPropertyFilter([$this->filter(null), $this->filter(null)]); + + self::assertNull($filter->filter($metadata, [])); + } + + public function testKeepsThePropertiesOfASingleFilter(): void + { + $metadata = (new AttributeMetadataFactory())->metadata(DefaultDto::class); + $properties = ['name' => $metadata->properties['name']]; + $filter = new ChainPropertyFilter([$this->filter(null), $this->filter($properties), $this->filter(null)]); + + self::assertSame($properties, $filter->filter($metadata, [])); + } + + public function testKeepsThePropertiesEveryFilterKeeps(): void + { + $metadata = (new AttributeMetadataFactory())->metadata(DefaultDto::class); + $properties = $metadata->properties; + + $filter = new ChainPropertyFilter([ + $this->filter(['name' => $properties['name'], 'email' => $properties['email']]), + $this->filter(null), + $this->filter(['email' => $properties['email'], 'admin' => $properties['admin']]), + ]); + + self::assertSame(['email' => $properties['email']], $filter->filter($metadata, [])); + } + + /** @param array|null $properties */ + private function filter(array|null $properties): PropertyFilter + { + $filter = $this->createStub(PropertyFilter::class); + $filter->method('filter')->willReturn($properties); + + return $filter; + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 99077e3..687a9fd 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -8,7 +8,9 @@ use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; +use Patchlevel\Hydrator\Mapper\ChainPropertyFilter; use Patchlevel\Hydrator\Mapper\MapperFactory; +use Patchlevel\Hydrator\Mapper\PropertyFilter; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\EnrichingMetadataFactory; use Patchlevel\Hydrator\Metadata\MetadataEnricher; @@ -214,4 +216,21 @@ public function testMapperFactoryDefaultPriority(): void self::assertSame([$high, $default, $low], $builder->mapperFactories()); } + + public function testAddPropertyFilter(): void + { + $filter1 = $this->createMock(PropertyFilter::class); + $filter2 = $this->createMock(PropertyFilter::class); + + $builder = new StackHydratorBuilder(); + + self::assertSame([], $builder->propertyFilters()); + self::assertNull($builder->build()->propertyFilter()); + + $builder->addPropertyFilter($filter1); + $builder->addPropertyFilter($filter2); + + self::assertSame([$filter1, $filter2], $builder->propertyFilters()); + self::assertEquals(new ChainPropertyFilter([$filter1, $filter2]), $builder->build()->propertyFilter()); + } } diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index cceec57..c75c8c4 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -13,6 +13,7 @@ use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Mapper\ChainPropertyFilter; use Patchlevel\Hydrator\Mapper\LazyMapper; use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Mapper\ReflectionMapper; @@ -30,6 +31,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextPropertyFilter; use Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\DummyMiddleware; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; @@ -1076,4 +1078,153 @@ public function testMiddlewareAfterASkippedOneStillRuns(): void self::assertSame(['hydrate first', 'extract first'], $calls->getArrayCopy()); } + + public function testPropertyFilter(): void + { + $first = new ContextPropertyFilter(); + $second = new ContextPropertyFilter(); + + self::assertNull((new StackHydrator())->propertyFilter()); + self::assertSame($first, (new StackHydrator(propertyFilters: [$first]))->propertyFilter()); + self::assertEquals( + new ChainPropertyFilter([$first, $second]), + (new StackHydrator(propertyFilters: [$first, $second]))->propertyFilter(), + ); + } + + public function testExtractWithPropertyFilter(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertSame( + ['email' => 'info@patchlevel.de'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['email']]]), + ); + self::assertSame( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [Skill::class => []]]), + ); + } + + public function testExtractWithPropertyFilterDoesNotReadFilteredProperties(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + $event = (new ReflectionClass(ProfileCreated::class))->newInstanceWithoutConstructor(); + $event->profileId = ProfileId::fromString('1'); + + self::assertSame( + ['profileId' => '1'], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]]), + ); + } + + public function testExtractWithPropertyFilterOfNestedObjects(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + $event = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + self::assertSame( + ['event' => ['profileId' => '1']], + $hydrator->extract($event, [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]]), + ); + } + + public function testExtractWithPropertyFilterBreaksCircularReference(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + + $dto1 = new Circle1Dto(); + $dto2 = new Circle2Dto(); + $dto3 = new Circle3Dto(); + + $dto1->to = $dto2; + $dto2->to = $dto3; + $dto3->to = $dto1; + + self::assertSame( + ['to' => ['to' => []]], + $hydrator->extract($dto1, [ContextPropertyFilter::FIELDS => [Circle3Dto::class => []]]), + ); + } + + public function testHydrateWithPropertyFilter(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + + $object = $hydrator->hydrate( + DefaultDto::class, + ['name' => 'test', 'email' => 'test@patchlevel.de', 'admin' => false], + [ContextPropertyFilter::FIELDS => [DefaultDto::class => ['name']]], + ); + + // filtered promoted properties fall back to their default, all others are not touched + self::assertEquals(new DefaultDto('test'), $object); + } + + public function testHydrateWithPropertyFilterAndAlias(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + + $object = $hydrator->hydrate( + AliasDto::class, + ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel'], + [ContextPropertyFilter::FIELDS => [AliasDto::class => ['email']]], + ); + + // kept properties are read from their aliases, the aliases of filtered properties are ignored + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de')), $object); + + $object = $hydrator->hydrate( + AliasDto::class, + ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel'], + [ContextPropertyFilter::FIELDS => [AliasDto::class => ['email', 'name']]], + ); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel'), $object); + } + + public function testHydrateWithPropertyFilterAndObjectToPopulate(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + $dto = new DefaultDto('old', Email::fromString('old@patchlevel.de')); + $dto->admin = false; + + $object = $hydrator->hydrate( + DefaultDto::class, + ['name' => 'new', 'email' => 'new@patchlevel.de', 'admin' => true], + [ + ContextPropertyFilter::FIELDS => [DefaultDto::class => ['name']], + Hydrator::OBJECT_TO_POPULATE => $dto, + ], + ); + + // the filtered properties of an existing object are not touched, also not set to their 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 testHydrateWithPropertyFilterOfNestedObjects(): void + { + $hydrator = new StackHydrator(propertyFilters: [new ContextPropertyFilter()]); + + $object = $hydrator->hydrate( + ProfileCreatedWrapper::class, + ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']], + [ContextPropertyFilter::FIELDS => [ProfileCreated::class => ['profileId']]], + ); + + self::assertEquals(ProfileId::fromString('1'), $object->profileCreated->profileId); + self::assertFalse(isset($object->profileCreated->email)); + } }