Skip to content
Merged
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
52 changes: 52 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,58 @@ always runs last.
```php
$builder->addMiddleware(new RemoveNullValuesMiddleware(), 0);
```
## Skipping middlewares

A middleware usually only has something to do for a few classes. Instead of
walking through the whole stack every time, a middleware can implement
`SkippableMiddleware` and tell the hydrator that it is not needed for a class.
The decision is made once per class and then reused, so it must only depend on
the metadata.

The `skip` method returns a `Skip` case: `Skip::None` to always run,
`Skip::Hydrate` or `Skip::Extract` to be left out in one direction only and
`Skip::Both` to be left out completely.

```php
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Skip;
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use Patchlevel\Hydrator\Middleware\Stack;

final class RemoveNullValuesMiddleware implements SkippableMiddleware
{
public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
{
return $stack->next()->hydrate($metadata, $data, $context, $stack);
}

public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array
{
$data = $stack->next()->extract($metadata, $object, $context, $stack);

return array_filter($data, static fn (mixed $value) => $value !== null);
}

public function skip(ClassMetadata $metadata): Skip
{
// the middleware only does something while extracting
return Skip::Hydrate;
}
}
```
The built-in middlewares use this as well: the `CryptographyMiddleware` is left
out for classes without sensitive data, the `LifecycleMiddleware` only runs in
the directions the class has hooks for, and the `UpcastMiddleware` is left out
while extracting, since upcasting only ever happens while hydrating. If every
upcaster is a `CallbackUpcaster` or carries an
[`#[UpcasterFor]`](upcasting.md#writing-an-upcaster) attribute, it is also left
out while hydrating classes none of them target.

:::warning
At least one middleware has to run. If every middleware skips a class, an
`AllMiddlewaresSkipped` exception is thrown.
:::

## Metadata enricher

A metadata enricher runs once per class when the metadata is created. It can
Expand Down
21 changes: 14 additions & 7 deletions docs/upcasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,31 +43,38 @@ up to date.

An upcaster implements the `Upcaster` interface. It receives the
[class metadata](hydrator.md), the data array and the context, and returns the
reshaped data. Because every registered upcaster runs for every class, check the
metadata and leave data you do not care about untouched.
reshaped data. Mark it with the `#[UpcasterFor]` attribute to restrict it to a
single class, so you do not have to check the metadata yourself:

```php
use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor;
use Patchlevel\Hydrator\Extension\Upcast\Upcaster;
use Patchlevel\Hydrator\Metadata\ClassMetadata;

#[UpcasterFor(ProfileCreated::class)]
final class RenameEmailUpcaster implements Upcaster
{
public function upcast(ClassMetadata $metadata, array $data, array $context): array
{
if ($metadata->className !== ProfileCreated::class) {
return $data;
}

$data['email'] = $data['mail'];
unset($data['mail']);

return $data;
}
}
```
:::note
`#[UpcasterFor]` is more than a convenience: the `UpcastMiddleware` reads it to
know it can be [skipped](extensions.md#skipping-middlewares) entirely for
classes it does not target. Without it, the upcaster is called for every
class, so if you do check the metadata yourself, the middleware has no way of
knowing and always calls it.
:::

For the common case of a single class and a closure, use the
`CallbackUpcaster`. It compares the class name for you and only invokes the
callback for a match. The callback receives the data and the context:
callback for a match, and the `UpcastMiddleware` recognizes it the same way it
recognizes `#[UpcasterFor]`. The callback receives the data and the context:

```php
use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
Expand Down
6 changes: 3 additions & 3 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ parameters:
path: src/Normalizer/ObjectNormalizer.php

-
message: '#^Parameter \#1 \$middlewares of class Patchlevel\\Hydrator\\Middleware\\Stack constructor expects non\-empty\-list\<Patchlevel\\Hydrator\\Middleware\\Middleware\>, list\<Patchlevel\\Hydrator\\Middleware\\Middleware\> given\.$#'
identifier: argument.type
count: 4
message: '#^Method Patchlevel\\Hydrator\\StackHydrator\:\:middlewaresFor\(\) should return non\-empty\-list\<Patchlevel\\Hydrator\\Middleware\\Middleware\> but returns list\<Patchlevel\\Hydrator\\Middleware\\Middleware\>\.$#'
identifier: return.type
count: 1
path: src/StackHydrator.php

-
Expand Down
25 changes: 23 additions & 2 deletions src/Extension/Cryptography/CryptographyMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Middleware;
use Patchlevel\Hydrator\Middleware\Skip;
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use Patchlevel\Hydrator\Middleware\Stack;
use Stringable;

Expand All @@ -18,7 +19,7 @@
use function is_int;
use function is_string;

final class CryptographyMiddleware implements Middleware
final class CryptographyMiddleware implements SkippableMiddleware
{
public function __construct(
private readonly Cryptographer $cryptographer,
Expand Down Expand Up @@ -117,6 +118,26 @@
return $data;
}

/**
* @param ClassMetadata<T> $metadata
*
* @template T of object
*/
public function skip(ClassMetadata $metadata): Skip
{
if (($metadata->extras[SubjectIdFieldMapping::class] ?? null) instanceof SubjectIdFieldMapping) {
return Skip::None;

Check warning on line 129 in src/Extension/Cryptography/CryptographyMiddleware.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ public function skip(ClassMetadata $metadata): Skip { if (($metadata->extras[SubjectIdFieldMapping::class] ?? null) instanceof SubjectIdFieldMapping) { - return Skip::None; + } foreach ($metadata->properties as $propertyMetadata) {
}

foreach ($metadata->properties as $propertyMetadata) {
if (($propertyMetadata->extras[SensitiveDataInfo::class] ?? null) instanceof SensitiveDataInfo) {
return Skip::None;
}
}

return Skip::Both;
}

/**
* @param array<string, mixed>|object $data
* @param array<string, mixed> $context
Expand Down
29 changes: 27 additions & 2 deletions src/Extension/Lifecycle/LifecycleMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
namespace Patchlevel\Hydrator\Extension\Lifecycle;

use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Middleware;
use Patchlevel\Hydrator\Middleware\Skip;
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use Patchlevel\Hydrator\Middleware\Stack;

use function assert;

final class LifecycleMiddleware implements Middleware
final class LifecycleMiddleware implements SkippableMiddleware
{
/**
* @param ClassMetadata<T> $metadata
Expand Down Expand Up @@ -67,4 +68,28 @@ public function extract(ClassMetadata $metadata, object $object, array $context,

return $data;
}

/**
* @param ClassMetadata<T> $metadata
*
* @template T of object
*/
public function skip(ClassMetadata $metadata): Skip
{
$lifecycle = $metadata->extras[Lifecycle::class] ?? null;

if (!$lifecycle instanceof Lifecycle) {
return Skip::Both;
}

$hydrate = $lifecycle->preHydrate !== null || $lifecycle->postHydrate !== null;
$extract = $lifecycle->preExtract !== null || $lifecycle->postExtract !== null;

return match (true) {
!$hydrate && !$extract => Skip::Both,
!$hydrate => Skip::Hydrate,
!$extract => Skip::Extract,
default => Skip::None,
};
}
}
17 changes: 17 additions & 0 deletions src/Extension/Upcast/Attribute/UpcasterFor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class UpcasterFor
{
/** @param class-string $className */
public function __construct(
public readonly string $className,
) {
}
}
2 changes: 1 addition & 1 deletion src/Extension/Upcast/CallbackUpcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class CallbackUpcaster implements Upcaster
* @param class-string $className
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
*/
public function __construct(private readonly string $className, callable $callback)
public function __construct(public readonly string $className, callable $callback)
{
$this->callback = Closure::fromCallable($callback);
}
Expand Down
66 changes: 63 additions & 3 deletions src/Extension/Upcast/UpcastMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@

namespace Patchlevel\Hydrator\Extension\Upcast;

use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Middleware;
use Patchlevel\Hydrator\Middleware\Skip;
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use Patchlevel\Hydrator\Middleware\Stack;
use ReflectionClass;

final readonly class UpcastMiddleware implements Middleware
use function array_map;

final readonly class UpcastMiddleware implements SkippableMiddleware
{
/** @var list<class-string|null> */
private array $targets;

/** @param list<Upcaster> $upcasters */
public function __construct(
private array $upcasters,
) {
$this->targets = array_map(self::resolveTarget(...), $upcasters);
}

/**
Expand All @@ -27,7 +36,13 @@
*/
public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
{
foreach ($this->upcasters as $upcaster) {
foreach ($this->upcasters as $index => $upcaster) {
$target = $this->targets[$index];

if ($target !== null && $target !== $metadata->className) {
continue;
}

$data = $upcaster->upcast($metadata, $data, $context);
}

Expand All @@ -47,4 +62,49 @@
{
return $stack->next()->extract($metadata, $object, $context, $stack);
}

/**
* @param ClassMetadata<T> $metadata
*
* @template T of object
*/
public function skip(ClassMetadata $metadata): Skip
{
// upcasting only ever happens while hydrating, extract() never does anything
if ($this->upcasters === []) {
return Skip::Both;

Check warning on line 75 in src/Extension/Upcast/UpcastMiddleware.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ { // upcasting only ever happens while hydrating, extract() never does anything if ($this->upcasters === []) { - return Skip::Both; + } foreach ($this->targets as $target) {
}

foreach ($this->targets as $target) {
// a target we could not resolve might still apply to this class
if ($target === null || $target === $metadata->className) {
return Skip::Extract;
}
}

// none of the upcasters targets this class
return Skip::Both;
}

/**
* Resolve the class an upcaster is restricted to, either from its
* `#[UpcasterFor]` attribute or, for a `CallbackUpcaster`, from the class
* name it was built with. Null means the upcaster applies to every class.
*
* @return class-string|null
*/
private static function resolveTarget(Upcaster $upcaster): string|null
{
if ($upcaster instanceof CallbackUpcaster) {
return $upcaster->className;
}

$attributes = (new ReflectionClass($upcaster))->getAttributes(UpcasterFor::class);

if ($attributes === []) {
return null;
}

return $attributes[0]->newInstance()->className;
}
}
21 changes: 21 additions & 0 deletions src/Middleware/AllMiddlewaresSkipped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Middleware;

use Patchlevel\Hydrator\HydratorException;
use RuntimeException;

use function sprintf;

final class AllMiddlewaresSkipped extends RuntimeException implements HydratorException
{
/** @param class-string $className */
public function __construct(string $className)
{
parent::__construct(
sprintf('All middlewares were skipped for the class "%s", at least one middleware must run.', $className),
);
}
}
20 changes: 20 additions & 0 deletions src/Middleware/Skip.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Middleware;

enum Skip
{
/** The middleware runs in both directions. */
case None;

/** The middleware is left out while hydrating, but runs while extracting. */
case Hydrate;

/** The middleware is left out while extracting, but runs while hydrating. */
case Extract;

/** The middleware is left out in both directions. */
case Both;
}
22 changes: 22 additions & 0 deletions src/Middleware/SkippableMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Middleware;

use Patchlevel\Hydrator\Metadata\ClassMetadata;

interface SkippableMiddleware extends Middleware
{
/**
* Decide for which directions this middleware has nothing to do for the given class.
*
* The result is determined once per class and then reused, so the decision
* must only depend on the metadata.
*
* @param ClassMetadata<T> $metadata
*
* @template T of object
*/
public function skip(ClassMetadata $metadata): Skip;
}
Loading
Loading