From 9282433fc622ba4e064a9252ddd0747b2fb3ae45 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 2 Oct 2026 22:59:45 +0200 Subject: [PATCH] Add groups extension Properties can be assigned to groups with the new Groups attribute and selected or excluded per call via the groups and ignored_groups context keys. Filtering applies to extract and hydrate and is passed on to nested objects. --- docs/extensions.md | 3 +- docs/groups.md | 161 +++++++++++ docs/project.json | 3 +- src/Extension/Groups/Attribute/Groups.php | 23 ++ src/Extension/Groups/GroupsExtension.php | 26 ++ .../Groups/GroupsMetadataEnricher.php | 35 +++ src/Extension/Groups/GroupsMiddleware.php | 197 ++++++++++++++ .../Groups/Fixture/AddressFixture.php | 18 ++ .../Extension/Groups/Fixture/ChildFixture.php | 18 ++ .../Groups/Fixture/NoGroupsFixture.php | 14 + .../Groups/Fixture/ParentFixture.php | 18 ++ .../Groups/Fixture/RecordingMiddleware.php | 46 ++++ .../Extension/Groups/Fixture/UserFixture.php | 23 ++ .../Extension/Groups/GroupsExtensionTest.php | 152 +++++++++++ .../Groups/GroupsMetadataEnricherTest.php | 44 +++ .../Extension/Groups/GroupsMiddlewareTest.php | 251 ++++++++++++++++++ 16 files changed, 1030 insertions(+), 2 deletions(-) create mode 100644 docs/groups.md create mode 100644 src/Extension/Groups/Attribute/Groups.php create mode 100644 src/Extension/Groups/GroupsExtension.php create mode 100644 src/Extension/Groups/GroupsMetadataEnricher.php create mode 100644 src/Extension/Groups/GroupsMiddleware.php create mode 100644 tests/Unit/Extension/Groups/Fixture/AddressFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ChildFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/NoGroupsFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ParentFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/RecordingMiddleware.php create mode 100644 tests/Unit/Extension/Groups/Fixture/UserFixture.php create mode 100644 tests/Unit/Extension/Groups/GroupsExtensionTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsMiddlewareTest.php diff --git a/docs/extensions.md b/docs/extensions.md index 9cd0f7f..a2217f1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -23,7 +23,7 @@ $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 | | --- | --- | @@ -31,6 +31,7 @@ The library ships with four extensions out of the box: | `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. | ## Middleware diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..ca8f7b7 --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,161 @@ +# 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\CoreExtension; +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new GroupsExtension()) + ->build(); +``` +## 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. + +```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 middlewares work](extensions.md) +* [How to use the hydrator](hydrator.md) +* [How to ignore a property completely](hydrator.md#ignore-properties) diff --git a/docs/project.json b/docs/project.json index 5fae563..66455fe 100644 --- a/docs/project.json +++ b/docs/project.json @@ -18,7 +18,8 @@ "subEntries": [ { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, { "title": "Cryptography", "file": "cryptography.md" }, - { "title": "Upcasting", "file": "upcasting.md" } + { "title": "Upcasting", "file": "upcasting.md" }, + { "title": "Groups", "file": "groups.md" } ] }, { diff --git a/src/Extension/Groups/Attribute/Groups.php b/src/Extension/Groups/Attribute/Groups.php new file mode 100644 index 0000000..40cd6f8 --- /dev/null +++ b/src/Extension/Groups/Attribute/Groups.php @@ -0,0 +1,23 @@ + */ + public readonly array $groups; + + /** @param string|array $groups */ + public function __construct(string|array $groups) + { + $this->groups = is_string($groups) ? [$groups] : array_values($groups); + } +} diff --git a/src/Extension/Groups/GroupsExtension.php b/src/Extension/Groups/GroupsExtension.php new file mode 100644 index 0000000..d178bb8 --- /dev/null +++ b/src/Extension/Groups/GroupsExtension.php @@ -0,0 +1,26 @@ +addMiddleware(new GroupsMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM); + $builder->addMetadataEnricher(new GroupsMetadataEnricher()); + } +} diff --git a/src/Extension/Groups/GroupsMetadataEnricher.php b/src/Extension/Groups/GroupsMetadataEnricher.php new file mode 100644 index 0000000..6451419 --- /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; + } + + // lets the middleware skip the filtering for classes without any groups + $classMetadata->extras[Groups::class] = true; + } +} diff --git a/src/Extension/Groups/GroupsMiddleware.php b/src/Extension/Groups/GroupsMiddleware.php new file mode 100644 index 0000000..d0cb199 --- /dev/null +++ b/src/Extension/Groups/GroupsMiddleware.php @@ -0,0 +1,197 @@ +}> */ + private array $selections = []; + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + if (!isset($context[GroupsExtension::GROUPS]) && !isset($context[GroupsExtension::IGNORED_GROUPS])) { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + foreach ($this->selection($metadata, $context)[1] as $fieldName) { + unset($data[$fieldName]); + } + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + if (!isset($context[GroupsExtension::GROUPS]) && !isset($context[GroupsExtension::IGNORED_GROUPS])) { + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + return $stack->next()->extract($this->selection($metadata, $context)[0], $object, $context, $stack); + } + + /** + * Returns the filtered metadata and the excluded field names. + * + * @param ClassMetadata $metadata + * @param array $context + * + * @return array{ClassMetadata, list} + * + * @template T of object + */ + private function selection(ClassMetadata $metadata, array $context): array + { + $groups = $context[GroupsExtension::GROUPS] ?? null; + + // ignored groups can't match a class without any groups + if ($groups === null && !isset($metadata->extras[Groups::class])) { + return [$metadata, []]; + } + + $ignoredGroups = $context[GroupsExtension::IGNORED_GROUPS] ?? null; + $key = $metadata->className . "\0" . $this->key($groups) . "\0" . $this->key($ignoredGroups); + + /** @var array{ClassMetadata, list} $selection */ + $selection = $this->selections[$key] ??= $this->createSelection( + $metadata, + $this->normalize($groups), + $this->normalize($ignoredGroups), + ); + + return $selection; + } + + /** + * @param ClassMetadata $metadata + * @param list|null $groups + * @param list|null $ignoredGroups + * + * @return array{ClassMetadata, list} + * + * @template T of object + */ + private function createSelection(ClassMetadata $metadata, array|null $groups, array|null $ignoredGroups): array + { + if ($groups !== null && in_array(GroupsExtension::ALL, $groups, true)) { + $groups = null; + } + + if ($groups === null && $ignoredGroups === null) { + return [$metadata, []]; + } + + $properties = []; + $excludedFields = []; + + 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) !== []) + ) { + $excludedFields[] = $propertyMetadata->fieldName; + + continue; + } + + $properties[] = $propertyMetadata; + } + + if ($excludedFields === []) { + return [$metadata, []]; + } + + return [ + new ClassMetadata( + $metadata->reflection, + $metadata->normalizer, + $properties, + $metadata->lazy, + $metadata->extras, + ), + $excludedFields, + ]; + } + + /** + * 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/tests/Unit/Extension/Groups/Fixture/AddressFixture.php b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php new file mode 100644 index 0000000..ac175ce --- /dev/null +++ b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php @@ -0,0 +1,18 @@ + */ + public array $data = []; + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $this->metadata = $metadata; + $this->data = $data; + + return $metadata->newInstance(); + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $this->metadata = $metadata; + + return []; + } +} diff --git a/tests/Unit/Extension/Groups/Fixture/UserFixture.php b/tests/Unit/Extension/Groups/Fixture/UserFixture.php new file mode 100644 index 0000000..fbada20 --- /dev/null +++ b/tests/Unit/Extension/Groups/Fixture/UserFixture.php @@ -0,0 +1,23 @@ + '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user()), + ); + } + + public function testExtractWithGroup(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'address' => ['city' => 'Berlin'], + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => 'public']), + ); + } + + public function testExtractWithMultipleGroups(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => ['public', 'admin']]), + ); + } + + public function testExtractWithAllGroup(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => GroupsExtension::ALL]), + ); + } + + public function testExtractWithIgnoredGroup(): void + { + self::assertSame( + [ + 'name' => 'John', + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::IGNORED_GROUPS => 'admin']), + ); + } + + public function testHydrateWithGroup(): void + { + $user = $this->hydrator()->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)); + } + + public function testGroupsBreakCircularReference(): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + self::assertSame( + ['name' => 'parent', 'child' => ['name' => 'child']], + $this->hydrator()->extract($parent, [GroupsExtension::GROUPS => 'parent']), + ); + + self::assertSame( + ['name' => 'child', 'parent' => ['name' => 'parent']], + $this->hydrator()->extract($parent->child, [GroupsExtension::GROUPS => 'child']), + ); + } + + public function testCircularReferenceWithoutGroups(): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + $this->expectException(CircularReference::class); + + $this->hydrator()->extract($parent); + } + + private function hydrator(): StackHydrator + { + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new GroupsExtension()) + ->build(); + } + + private function user(): UserFixture + { + return new UserFixture( + '1', + 'John', + 'john@example.com', + new AddressFixture('Berlin', 'Main Street'), + 'secret', + ); + } +} diff --git a/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php new file mode 100644 index 0000000..9ea1e9c --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php @@ -0,0 +1,44 @@ +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 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/GroupsMiddlewareTest.php b/tests/Unit/Extension/Groups/GroupsMiddlewareTest.php new file mode 100644 index 0000000..682ae2d --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsMiddlewareTest.php @@ -0,0 +1,251 @@ +}> */ + public static function provideGroups(): iterable + { + yield 'no groups' => [null, ['id', 'name', 'email', 'address', 'internal']]; + yield 'empty list' => [[], ['id', 'name', 'email', 'address', 'internal']]; + yield 'invalid value' => [42, ['id', 'name', 'email', 'address', 'internal']]; + yield 'only invalid entries' => [[42, null], ['id', 'name', 'email', 'address', 'internal']]; + 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], ['id', 'name', 'email', 'address', 'internal']]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideGroups')] + public function testExtract(mixed $groups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [GroupsExtension::GROUPS => $groups], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideGroups')] + public function testHydrate(mixed $groups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + $metadata = $this->metadata(); + + (new GroupsMiddleware())->hydrate( + $metadata, + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => [], + 'internal' => 'secret', + 'unknown' => 'value', + ], + [GroupsExtension::GROUPS => $groups], + new Stack([$inner]), + ); + + self::assertSame($metadata, $inner->metadata); + + // fields without a property are not touched + self::assertSame([...$expectedProperties, 'unknown'], array_keys($inner->data)); + } + + /** @return iterable}> */ + public static function provideIgnoredGroups(): iterable + { + yield 'string' => [null, 'admin', ['name', 'internal']]; + yield 'list' => [null, ['admin', 'public'], ['internal']]; + yield 'unknown group' => [null, ['unknown'], ['id', 'name', 'email', 'address', 'internal']]; + yield 'empty list' => [null, [], ['id', 'name', 'email', 'address', 'internal']]; + yield 'with groups' => ['public', 'admin', ['name']]; + yield 'with all' => [GroupsExtension::ALL, 'admin', ['name', 'internal']]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideIgnoredGroups')] + public function testExtractWithIgnoredGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideIgnoredGroups')] + public function testHydrateWithIgnoredGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->hydrate( + $this->metadata(), + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => [], + 'internal' => 'secret', + ], + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertSame($expectedProperties, array_keys($inner->data)); + } + + /** @return iterable}> */ + public static function provideClassWithoutGroups(): iterable + { + yield 'groups' => ['public', null, []]; + yield 'all' => [GroupsExtension::ALL, null, ['name', 'age']]; + yield 'ignored groups' => [null, 'admin', ['name', 'age']]; + yield 'groups and ignored groups' => ['public', 'admin', []]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideClassWithoutGroups')] + public function testExtractClassWithoutGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->noGroupsMetadata(), + new NoGroupsFixture(), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideClassWithoutGroups')] + public function testHydrateClassWithoutGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + $metadata = $this->noGroupsMetadata(); + + (new GroupsMiddleware())->hydrate( + $metadata, + ['name' => 'John', 'age' => 42], + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertSame($metadata, $inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->data)); + } + + public function testSelectionIsReused(): void + { + $middleware = new GroupsMiddleware(); + $metadata = $this->metadata(); + + $first = new RecordingMiddleware(); + $middleware->extract($metadata, $this->user(), [GroupsExtension::GROUPS => 'public'], new Stack([$first])); + + $second = new RecordingMiddleware(); + $middleware->extract($metadata, $this->user(), [GroupsExtension::GROUPS => ['public']], new Stack([$second])); + + $third = new RecordingMiddleware(); + $middleware->extract( + $metadata, + $this->user(), + [GroupsExtension::GROUPS => 'public', GroupsExtension::IGNORED_GROUPS => 'admin'], + new Stack([$third]), + ); + + self::assertNotSame($metadata, $first->metadata); + self::assertSame($first->metadata, $second->metadata); + self::assertNotSame($first->metadata, $third->metadata); + } + + public function testEmptyStringGroupIsNotTreatedAsMissing(): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [GroupsExtension::GROUPS => ''], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame([], $inner->metadata->properties); + } + + /** @return ClassMetadata */ + private function metadata(): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata(UserFixture::class); + (new GroupsMetadataEnricher())->enrich($metadata); + + return $metadata; + } + + /** @return ClassMetadata */ + private function noGroupsMetadata(): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata(NoGroupsFixture::class); + (new GroupsMetadataEnricher())->enrich($metadata); + + return $metadata; + } + + private function user(): UserFixture + { + return new UserFixture('1', 'John', 'john@example.com', new AddressFixture('Berlin', 'Main Street')); + } +}