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
60 changes: 60 additions & 0 deletions docs/UPGRADE-3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 71 additions & 5 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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)
5 changes: 3 additions & 2 deletions docs/generated-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions docs/groups.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions docs/hydrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
},
Expand Down
Loading
Loading