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
64 changes: 64 additions & 0 deletions docs/upcasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,70 @@ values, while a `beforeMapping` upcaster sees the decrypted ones. Pick the
stage that matches the data you need.
:::

## Versions

Upcasters usually have to find out the shape of the stored data themselves,
for example by checking which fields exist. If you store a version together
with the data, the upcasters can rely on it instead. Give each upcaster the
version of the data it expects with `from`, it returns the data of the next
version. Pass the version of the stored data per class in the context with
`UpcastExtension::VERSIONS`.

```php
use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
->useExtension(new UpcastExtension(
beforeMapping: [
CallbackUpcaster::forClass(
ProfileCreated::class,
static function (array $data): array {
$data['name'] = $data['firstName'] . ' ' . $data['lastName'];
unset($data['firstName'], $data['lastName']);

return $data;
},
from: 1,
),
CallbackUpcaster::forClass(
ProfileCreated::class,
static function (array $data): array {
$data['email'] = strtolower($data['email']);

return $data;
},
from: 2,
),
],
))
->build();

$profile = $hydrator->hydrate(
ProfileCreated::class,
$payload,
[UpcastExtension::VERSIONS => [ProfileCreated::class => $version]],
);
```
Data of version 1 runs through both upcasters, data of version 2 only through
the second one and data of version 3, the current one, through none of them.
Upcasters without a version run for data of every version, as before. An
upcaster class gets its version with the attribute,
`#[UpcasterFor(ProfileCreated::class, from: 1)]`.

:::note
Without a version in the context the data is treated as up to date, the
versioned upcasters don't run. The version only applies to the given class, the
data of nested objects is part of its shape.
:::

:::warning
Register the versioned upcasters of a class in the order of their versions,
the `UpcastMiddleware` throws an `UpcastersNotInVersionOrder` exception
otherwise.
:::

## Learn more

* [How to write your own extension](extensions.md)
Expand Down
8 changes: 7 additions & 1 deletion src/Extension/Upcast/Attribute/UpcasterFor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
#[Attribute(Attribute::TARGET_CLASS)]
final class UpcasterFor
{
/** @param class-string $className */
/**
* @param class-string $className
* @param int|null $from the version of the data the upcaster expects, it returns the data of the next
* version. The upcaster then only runs for data of this or an older version, see
* {@see \Patchlevel\Hydrator\Extension\Upcast\UpcastExtension::VERSIONS}.
*/
public function __construct(
public readonly string $className,
public readonly int|null $from = null,
) {
}
}
13 changes: 9 additions & 4 deletions src/Extension/Upcast/CallbackUpcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,24 @@ final class CallbackUpcaster implements Upcaster
/**
* @param class-string $className
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
* @param int|null $from the version of the data the callback expects, see {@see Attribute\UpcasterFor::$from}
*/
public function __construct(public readonly string $className, callable $callback)
{
public function __construct(
public readonly string $className,
callable $callback,
public readonly int|null $from = null,
) {
$this->callback = Closure::fromCallable($callback);
}

/**
* @param class-string $className
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
* @param int|null $from the version of the data the callback expects, see {@see Attribute\UpcasterFor::$from}
*/
public static function forClass(string $className, callable $callback): self
public static function forClass(string $className, callable $callback, int|null $from = null): self
{
return new self($className, $callback);
return new self($className, $callback, $from);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/Extension/Upcast/UpcastExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

final readonly class UpcastExtension implements Extension
{
/**
* Context key with the version of the hydrated data by class, for example [ProfileCreated::class => 1]. Versioned
* upcasters only run for data of their version or older, without a version the data is treated as up to date.
*/
public const VERSIONS = 'upcast_versions';

/**
* @param list<Upcaster> $beforeEncoding upcasters that reshape the raw stored payload before its values are decoded
* @param list<Upcaster> $beforeMapping upcasters that run after value decoding, right before the object is built
Expand Down
87 changes: 77 additions & 10 deletions src/Extension/Upcast/UpcastMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,52 @@
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use ReflectionClass;

use function array_map;
use function is_array;
use function is_int;

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

/** @param list<Upcaster> $upcasters */
/** @var list<int|null> the version of the data each upcaster expects, null for data of every version */
private array $versions;

private bool $versioned;

/**
* @param list<Upcaster> $upcasters
*
* @throws UpcastersNotInVersionOrder if a versioned upcaster comes after one of a newer version of the same class.
*/
public function __construct(
private array $upcasters,
) {
$this->targets = array_map(self::resolveTarget(...), $upcasters);
$targets = [];
$versions = [];

/** @var array<class-string, int> $previous */
$previous = [];

foreach ($upcasters as $upcaster) {
[$target, $version] = self::resolveTarget($upcaster);
$targets[] = $target;
$versions[] = $version;

if ($target === null || $version === null) {
continue;
}

if (isset($previous[$target]) && $version < $previous[$target]) {
throw new UpcastersNotInVersionOrder($target, $previous[$target], $version);
}

$previous[$target] = $version;
}

$this->targets = $targets;
$this->versions = $versions;
$this->versioned = $previous !== [];
}

/**
Expand All @@ -37,13 +71,22 @@ public function __construct(
*/
public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object
{
$version = $this->versioned ? self::version($metadata, $context) : null;

foreach ($this->upcasters as $index => $upcaster) {
$target = $this->targets[$index];

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

$from = $this->versions[$index];

// the data is already newer than the version the upcaster expects, or up to date without a version
if ($from !== null && ($version === null || $from < $version)) {
continue;
}

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

Expand Down Expand Up @@ -89,24 +132,48 @@ public function skip(ClassMetadata $metadata): Skip
}

/**
* 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.
* Resolve the class an upcaster is restricted to and the version of the data
* it expects, either from its `#[UpcasterFor]` attribute or, for a
* `CallbackUpcaster`, from the values it was built with. A null class means
* the upcaster applies to every class, a null version to data of every version.
*
* @return class-string|null
* @return array{class-string|null, int|null}
*/
private static function resolveTarget(Upcaster $upcaster): string|null
private static function resolveTarget(Upcaster $upcaster): array
{
if ($upcaster instanceof CallbackUpcaster) {
return $upcaster->className;
return [$upcaster->className, $upcaster->from];
}

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

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

$attribute = $attributes[0]->newInstance();

return [$attribute->className, $attribute->from];
}

/**
* The version of the hydrated data, see {@see UpcastExtension::VERSIONS}.
*
* @param ClassMetadata<T> $metadata
* @param array<string, mixed> $context
*
* @template T of object
*/
private static function version(ClassMetadata $metadata, array $context): int|null
{
$versions = $context[UpcastExtension::VERSIONS] ?? null;

if (!is_array($versions)) {
return null;
}

return $attributes[0]->newInstance()->className;
$version = $versions[$metadata->className] ?? null;

return is_int($version) ? $version : null;
}
}
24 changes: 24 additions & 0 deletions src/Extension/Upcast/UpcastersNotInVersionOrder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast;

use InvalidArgumentException;
use Patchlevel\Hydrator\HydratorException;

use function sprintf;

final class UpcastersNotInVersionOrder extends InvalidArgumentException implements HydratorException
{
/** @param class-string $class */
public function __construct(string $class, int $previous, int $from)
{
parent::__construct(sprintf(
'The upcasters of %s have to be registered in the order of their versions, the upcaster from version %d comes after the one from version %d.',
$class,
$from,
$previous,
));
}
}
7 changes: 7 additions & 0 deletions tests/Unit/Extension/Upcast/CallbackUpcasterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ static function (array $data, array $context): array {
);
}

public function testVersion(): void
{
self::assertNull(CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data)->from);
self::assertSame(2, CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, 2)->from);
self::assertSame(3, (new CallbackUpcaster(UpcastFixture::class, static fn (array $data): array => $data, 3))->from);
}

public function testSkipDifferentClass(): void
{
$metadata = (new AttributeMetadataFactory())->metadata(LifecycleFixture::class);
Expand Down
39 changes: 39 additions & 0 deletions tests/Unit/Extension/Upcast/UpcastExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,45 @@ static function (array $data): array {
self::assertSame('Jane Doe', $object->name);
}

public function testIntegrationWithVersions(): void
{
$hydrator = (new StackHydratorBuilder())
->useExtension(new UpcastExtension(
beforeMapping: [
CallbackUpcaster::forClass(
UpcastFixture::class,
static function (array $data): array {
$firstName = $data['firstName'] ?? '';
$lastName = $data['lastName'] ?? '';
assert(is_string($firstName));
assert(is_string($lastName));

return ['name' => $firstName . ' ' . $lastName];
},
from: 1,
),
],
))
->build();

$object = $hydrator->hydrate(
UpcastFixture::class,
['firstName' => 'Jane', 'lastName' => 'Doe'],
[UpcastExtension::VERSIONS => [UpcastFixture::class => 1]],
);

self::assertSame('Jane Doe', $object->name);

// data of the current version is left alone
$object = $hydrator->hydrate(
UpcastFixture::class,
['name' => 'John Doe'],
[UpcastExtension::VERSIONS => [UpcastFixture::class => 2]],
);

self::assertSame('John Doe', $object->name);
}

public function testConfigureAroundCryptography(): void
{
$beforeEncodingUpcaster = CallbackUpcaster::forClass(
Expand Down
Loading
Loading