Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ $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 |
| --- | --- |
| `CoreExtension` | The default behaviour, the `TransformMiddleware` and the `BuiltInGuesser`. |
| `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

Expand Down
161 changes: 161 additions & 0 deletions docs/groups.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion docs/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
},
{
Expand Down
23 changes: 23 additions & 0 deletions src/Extension/Groups/Attribute/Groups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Groups\Attribute;

use Attribute;

use function array_values;
use function is_string;

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Groups
{
/** @var list<string> */
public readonly array $groups;

/** @param string|array<string> $groups */
public function __construct(string|array $groups)
{
$this->groups = is_string($groups) ? [$groups] : array_values($groups);
}
}
26 changes: 26 additions & 0 deletions src/Extension/Groups/GroupsExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Groups;

use Patchlevel\Hydrator\Extension;
use Patchlevel\Hydrator\StackHydratorBuilder;

final readonly class GroupsExtension implements Extension
{
/** Context key to select the groups, accepts a string or a list of strings. */
public const GROUPS = 'groups';

/** Context key to exclude groups, accepts a string or a list of strings. */
public const IGNORED_GROUPS = 'ignored_groups';

/** Group that matches every property, including the ones without groups. */
public const ALL = '*';

public function configure(StackHydratorBuilder $builder): void
{
$builder->addMiddleware(new GroupsMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM);
$builder->addMetadataEnricher(new GroupsMetadataEnricher());
}
}
35 changes: 35 additions & 0 deletions src/Extension/Groups/GroupsMetadataEnricher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Groups;

use Patchlevel\Hydrator\Extension\Groups\Attribute\Groups;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Metadata\MetadataEnricher;

final class GroupsMetadataEnricher implements MetadataEnricher
{
public function enrich(ClassMetadata $classMetadata): void
{
$hasGroups = false;

foreach ($classMetadata->properties as $property) {
$attributeReflectionList = $property->reflection->getAttributes(Groups::class);

if ($attributeReflectionList === []) {
continue;

Check warning on line 21 in src/Extension/Groups/GroupsMetadataEnricher.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Continue_": @@ @@ $attributeReflectionList = $property->reflection->getAttributes(Groups::class); if ($attributeReflectionList === []) { - continue; + break; } $property->extras[Groups::class] = $attributeReflectionList[0]->newInstance()->groups;
}

$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;
}
}
Loading
Loading