diff --git a/docs/UPGRADE-3.0.md b/docs/UPGRADE-3.0.md index 8c92e3b3..40bd55b2 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 @@ -349,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 8bedc563..9d92b0ec 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 6d003b11..0e165ddc 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 00000000..aa08a86a --- /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/hydrator.md b/docs/hydrator.md index 4ef2e7db..37209939 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/docs/project.json b/docs/project.json index d7fd1f33..348791cb 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/ClassPlan.php b/src/Extension/Generated/ClassPlan.php index 5177fa46..2286a5d8 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 c7452733..18b550f2 100644 --- a/src/Extension/Generated/CodeEmitter.php +++ b/src/Extension/Generated/CodeEmitter.php @@ -78,10 +78,12 @@ 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(); + [$extractFiltered, $filteredHelpers] = $this->extractFilteredBody(); - foreach ([...$hydrateHelpers, ...$extractHelpers] as $helper) { + foreach ([...$hydrateHelpers, ...$populateHelpers, ...$extractHelpers, ...$filteredHelpers] as $helper) { $initialize[] = $helper; } @@ -95,6 +97,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) { @@ -107,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) { @@ -122,16 +146,22 @@ 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'), 'extractNested' => self::withInline($extract, 'true'), + 'extractFiltered' => $extractFiltered, 'initialize' => $initialize === [] ? '// nothing to resolve' : implode("\n", $initialize), 'recursive' => $this->recursive(), ]); // 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 +210,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 +247,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 @@ -335,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 00000000..88c40641 --- /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 6af6e333..52a49324 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 f41da6a1..b428b73b 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 3fb90349..a533c82c 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 = 13; public function __construct( public readonly string $cachePath, diff --git a/src/Extension/Generated/Templates.php b/src/Extension/Generated/Templates.php index c0317c3b..122362b3 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,13 @@ 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 { {{initialize}} @@ -137,6 +145,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' @@ -165,11 +186,7 @@ protected function mayReachItself(): bool $this->callStack->objects[$id] = \{{class}}::class; try { - {{pre}} - - return [ - {{fields}} - ]; + {{body}} } finally { unset($this->callStack->objects[$id]); } @@ -197,6 +214,34 @@ protected function mayReachItself(): bool 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 00000000..40cd6f8d --- /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 00000000..e36ecdce --- /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 00000000..b5cc4b5b --- /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 00000000..5505172f --- /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 00000000..86c9d9d8 --- /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 00000000..230ff390 --- /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 babcd1c9..e44fb17b 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(); } /** @@ -60,17 +65,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,20 +81,29 @@ 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; + 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); if ($alias === null) { - if (!$propertyMetadata->reflection->isPromoted()) { + if ($populate || !$propertyMetadata->reflection->isPromoted()) { continue; } @@ -151,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. * @@ -187,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)) { @@ -201,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 eff80564..66117809 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 6157d4c7..4a5d31d4 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 626fce65..d6d81149 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 f76a6f10..5ddd213a 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; @@ -60,6 +62,8 @@ 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\PrivateParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer; @@ -99,6 +103,7 @@ #[CoversClass(MapperGenerator::class)] #[CoversClass(GeneratedMapperWarmer::class)] #[CoversClass(GeneratedMapper::class)] +#[CoversClass(FilteredMapper::class)] #[CoversClass(MapperFiles::class)] #[CoversClass(ClassPlanner::class)] #[CoversClass(ClassPlan::class)] @@ -126,6 +131,7 @@ final class GeneratedStackHydratorTest extends TestCase ProfileCreatedWithInlineNormalizer::class, PrivatePropertiesDto::class, PrivateChildDto::class, + PrivateDefaultChildDto::class, SensitiveDataProfileCreated::class, NestedLifecycleDto::class, AliasDto::class, @@ -509,6 +515,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); @@ -938,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; @@ -947,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; @@ -1096,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 6bff3094..44a9e01a 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 3b7221d3..08aed225 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,107 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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)) { + $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 edcac335..c7c5f904 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 ccb0f9fd..fa5fa457 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 d62ad60e..73eab7c9 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 6de0b8f8..6b36fba9 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 c8d31234..5e57bc78 100644 --- a/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -254,6 +256,123 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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; + + 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 505368e7..6cdfbf6e 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 d18881a7..f33d9b93 100644 --- a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -552,6 +554,285 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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)) { + 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 891b8fd1..f89db5be 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,229 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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)) { + 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 ff945396..5e9e6bab 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 b9195766..160d8b64 100644 --- a/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php +++ b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php @@ -1,7 +1,7 @@ populate($object, $data, $context); } else { $object = $this->reflection->newInstanceWithoutConstructor(); } @@ -344,6 +346,161 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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; + + 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 a4a620d6..3dc870bc 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,331 @@ public function extractNested(object $object, array $context): array ]; } + public function extractFiltered(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\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; + + 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 5c8c3c93..426c6ca0 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 1e0fb37e..bccf2aef 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 new file mode 100644 index 00000000..c3838e96 --- /dev/null +++ b/tests/Unit/Extension/Generated/Snapshot/PrivateDefaultChildDtoMapper.php @@ -0,0 +1,199 @@ +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, + ]; + } + + public function extractFiltered(object $object, array $context, array $fields, bool $inline): array + { + $data = []; + + $s3 = ($this->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; + + 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); + $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 + { + 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 9265c88f..b9941f65 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 +83,18 @@ public function extractNested(object $object, array $context): array return ($this->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; + + return ($this->p)($object, $data, $context, $inline); + } + protected function initialize(): void { $this->n0 = $this->normalizer('profileId'); @@ -121,6 +137,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); @@ -167,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 4302d55e..e57111aa 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 b8808c31..2c360b4c 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 07839025..e81f9156 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 ca86e822..f853e3ca 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 8e0e712e..d7f42388 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 00000000..ac175ce1 --- /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 00000000..1d81a6a2 --- /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 00000000..d7594759 --- /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 00000000..ccf87fa1 --- /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/Fixture/PrivateDefaultChildDto.php b/tests/Unit/Fixture/PrivateDefaultChildDto.php new file mode 100644 index 00000000..10c54a51 --- /dev/null +++ b/tests/Unit/Fixture/PrivateDefaultChildDto.php @@ -0,0 +1,10 @@ +note; + } +} diff --git a/tests/Unit/Mapper/ChainPropertyFilterTest.php b/tests/Unit/Mapper/ChainPropertyFilterTest.php new file mode 100644 index 00000000..54cf7c48 --- /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 99077e34..687a9fd1 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 53eee9b0..c75c8c47 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; @@ -374,6 +376,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); @@ -1043,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)); + } }