Skip to content

Commit ea0db54

Browse files
committed
add upcaster extension
1 parent c5e1037 commit ea0db54

9 files changed

Lines changed: 430 additions & 0 deletions

File tree

phpstan-baseline.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ parameters:
198198
count: 1
199199
path: tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataWithIdentifierDto.php
200200

201+
-
202+
message: '#^Binary operation "\." between mixed and '' '' results in an error\.$#'
203+
identifier: binaryOp.invalid
204+
count: 1
205+
path: tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php
206+
207+
-
208+
message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#'
209+
identifier: binaryOp.invalid
210+
count: 1
211+
path: tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php
212+
201213
-
202214
message: '#^Method Patchlevel\\Hydrator\\Tests\\Unit\\Fixture\\DtoWithHooks\:\:postHydrate\(\) is unused\.$#'
203215
identifier: method.unused
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Upcast;
6+
7+
use Closure;
8+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
9+
10+
/** @experimental */
11+
final class CallbackUpcaster implements Upcaster
12+
{
13+
/** @var Closure(array<string, mixed>, array<string, mixed>): array<string, mixed> */
14+
private readonly Closure $callback;
15+
16+
/**
17+
* @param class-string $className
18+
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
19+
*/
20+
public function __construct(private readonly string $className, callable $callback)
21+
{
22+
$this->callback = Closure::fromCallable($callback);
23+
}
24+
25+
/**
26+
* @param class-string $className
27+
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
28+
*/
29+
public static function forClass(string $className, callable $callback): self
30+
{
31+
return new self($className, $callback);
32+
}
33+
34+
/**
35+
* @param ClassMetadata<T> $metadata
36+
* @param array<string, mixed> $data
37+
* @param array<string, mixed> $context
38+
*
39+
* @return array<string, mixed>
40+
*
41+
* @template T of object
42+
*/
43+
public function upcast(ClassMetadata $metadata, array $data, array $context): array
44+
{
45+
if ($metadata->className !== $this->className) {
46+
return $data;
47+
}
48+
49+
return ($this->callback)($data, $context);
50+
}
51+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Upcast;
6+
7+
use Patchlevel\Hydrator\Extension;
8+
use Patchlevel\Hydrator\StackHydratorBuilder;
9+
10+
/** @experimental */
11+
final readonly class UpcastExtension implements Extension
12+
{
13+
private const BEFORE_CRYPTOGRAPHY_PRIORITY = 66;
14+
private const AFTER_CRYPTOGRAPHY_PRIORITY = 63;
15+
16+
/**
17+
* @param list<Upcaster> $beforeCryptography
18+
* @param list<Upcaster> $afterCryptography
19+
*/
20+
public function __construct(
21+
private array $beforeCryptography = [],
22+
private array $afterCryptography = [],
23+
) {
24+
}
25+
26+
public function configure(StackHydratorBuilder $builder): void
27+
{
28+
if ($this->beforeCryptography !== []) {
29+
$builder->addMiddleware(
30+
new UpcastMiddleware($this->beforeCryptography),
31+
self::BEFORE_CRYPTOGRAPHY_PRIORITY,
32+
);
33+
}
34+
35+
if ($this->afterCryptography === []) {
36+
return;
37+
}
38+
39+
$builder->addMiddleware(
40+
new UpcastMiddleware($this->afterCryptography),
41+
self::AFTER_CRYPTOGRAPHY_PRIORITY,
42+
);
43+
}
44+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Upcast;
6+
7+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
8+
use Patchlevel\Hydrator\Middleware\Middleware;
9+
use Patchlevel\Hydrator\Middleware\Stack;
10+
11+
/** @experimental */
12+
final readonly class UpcastMiddleware implements Middleware
13+
{
14+
/** @param list<Upcaster> $upcasters */
15+
public function __construct(
16+
private array $upcasters,
17+
) {
18+
}
19+
20+
/**
21+
* @param ClassMetadata<T> $metadata
22+
* @param array<string, mixed> $data
23+
* @param array<string, mixed> $context
24+
*
25+
* @return T
26+
*
27+
* @template T of object
28+
*/
29+
public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
30+
{
31+
foreach ($this->upcasters as $upcaster) {
32+
$data = $upcaster->upcast($metadata, $data, $context);
33+
}
34+
35+
return $stack->next()->hydrate($metadata, $data, $context, $stack);
36+
}
37+
38+
/**
39+
* @param ClassMetadata<T> $metadata
40+
* @param T $object
41+
* @param array<string, mixed> $context
42+
*
43+
* @return array<string, mixed>
44+
*
45+
* @template T of object
46+
*/
47+
public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array
48+
{
49+
return $stack->next()->extract($metadata, $object, $context, $stack);
50+
}
51+
}

src/Extension/Upcast/Upcaster.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Upcast;
6+
7+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
8+
9+
/** @experimental */
10+
interface Upcaster
11+
{
12+
/**
13+
* @param ClassMetadata<T> $metadata
14+
* @param array<string, mixed> $data
15+
* @param array<string, mixed> $context
16+
*
17+
* @return array<string, mixed>
18+
*
19+
* @template T of object
20+
*/
21+
public function upcast(ClassMetadata $metadata, array $data, array $context): array;
22+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast;
6+
7+
use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
8+
use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory;
9+
use Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle\Fixture\LifecycleFixture;
10+
use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\TestCase;
13+
14+
use function assert;
15+
use function is_string;
16+
17+
#[CoversClass(CallbackUpcaster::class)]
18+
final class CallbackUpcasterTest extends TestCase
19+
{
20+
public function testUpcast(): void
21+
{
22+
$metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class);
23+
$upcaster = CallbackUpcaster::forClass(
24+
UpcastFixture::class,
25+
static function (array $data, array $context): array {
26+
$prefix = $context['prefix'] ?? '';
27+
$name = $data['name'] ?? '';
28+
assert(is_string($prefix));
29+
assert(is_string($name));
30+
31+
$data['name'] = $prefix . $name;
32+
33+
return $data;
34+
},
35+
);
36+
37+
self::assertSame(
38+
['name' => 'Upcast: foo'],
39+
$upcaster->upcast($metadata, ['name' => 'foo'], ['prefix' => 'Upcast: ']),
40+
);
41+
}
42+
43+
public function testSkipDifferentClass(): void
44+
{
45+
$metadata = (new AttributeMetadataFactory())->metadata(LifecycleFixture::class);
46+
$upcaster = CallbackUpcaster::forClass(
47+
UpcastFixture::class,
48+
static fn (array $data): array => ['name' => 'changed'],
49+
);
50+
51+
self::assertSame(['name' => 'foo'], $upcaster->upcast($metadata, ['name' => 'foo'], []));
52+
}
53+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture;
6+
7+
final class UpcastFixture
8+
{
9+
public function __construct(
10+
public string $name,
11+
) {
12+
}
13+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast;
6+
7+
use Patchlevel\Hydrator\CoreExtension;
8+
use Patchlevel\Hydrator\Cryptography\PayloadCryptographer;
9+
use Patchlevel\Hydrator\Extension\Cryptography\Cryptographer;
10+
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
11+
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyMiddleware;
12+
use Patchlevel\Hydrator\Extension\Cryptography\LegacyCryptographyDecryptMiddleware;
13+
use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
14+
use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension;
15+
use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware;
16+
use Patchlevel\Hydrator\StackHydratorBuilder;
17+
use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture;
18+
use PHPUnit\Framework\Attributes\CoversClass;
19+
use PHPUnit\Framework\TestCase;
20+
21+
use function assert;
22+
use function is_string;
23+
24+
#[CoversClass(UpcastExtension::class)]
25+
final class UpcastExtensionTest extends TestCase
26+
{
27+
public function testIntegration(): void
28+
{
29+
$hydrator = (new StackHydratorBuilder())
30+
->useExtension(new CoreExtension())
31+
->useExtension(new UpcastExtension(
32+
afterCryptography: [
33+
CallbackUpcaster::forClass(
34+
UpcastFixture::class,
35+
static function (array $data): array {
36+
$firstName = $data['firstName'] ?? '';
37+
$lastName = $data['lastName'] ?? '';
38+
assert(is_string($firstName));
39+
assert(is_string($lastName));
40+
41+
$data['name'] = $firstName . ' ' . $lastName;
42+
43+
return $data;
44+
},
45+
),
46+
],
47+
))
48+
->build();
49+
50+
$object = $hydrator->hydrate(UpcastFixture::class, ['firstName' => 'Jane', 'lastName' => 'Doe']);
51+
52+
self::assertSame('Jane Doe', $object->name);
53+
}
54+
55+
public function testConfigureAroundCryptography(): void
56+
{
57+
$beforeCryptographyUpcaster = CallbackUpcaster::forClass(
58+
UpcastFixture::class,
59+
static fn (array $data): array => $data,
60+
);
61+
$afterCryptographyUpcaster = CallbackUpcaster::forClass(
62+
UpcastFixture::class,
63+
static fn (array $data): array => $data,
64+
);
65+
66+
$builder = new StackHydratorBuilder();
67+
$builder->useExtension(new UpcastExtension(
68+
beforeCryptography: [$beforeCryptographyUpcaster],
69+
afterCryptography: [$afterCryptographyUpcaster],
70+
));
71+
$builder->useExtension(new CryptographyExtension($this->createMock(Cryptographer::class)));
72+
73+
$middlewares = $builder->middlewares();
74+
75+
self::assertCount(3, $middlewares);
76+
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]);
77+
self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[1]);
78+
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[2]);
79+
}
80+
81+
public function testConfigureAroundLegacyCryptography(): void
82+
{
83+
$beforeCryptographyUpcaster = CallbackUpcaster::forClass(
84+
UpcastFixture::class,
85+
static fn (array $data): array => $data,
86+
);
87+
$afterCryptographyUpcaster = CallbackUpcaster::forClass(
88+
UpcastFixture::class,
89+
static fn (array $data): array => $data,
90+
);
91+
92+
$builder = new StackHydratorBuilder();
93+
$builder->useExtension(new UpcastExtension(
94+
beforeCryptography: [$beforeCryptographyUpcaster],
95+
afterCryptography: [$afterCryptographyUpcaster],
96+
));
97+
$builder->useExtension(new CryptographyExtension(
98+
$this->createMock(Cryptographer::class),
99+
$this->createMock(PayloadCryptographer::class),
100+
));
101+
102+
$middlewares = $builder->middlewares();
103+
104+
self::assertCount(4, $middlewares);
105+
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]);
106+
self::assertInstanceOf(LegacyCryptographyDecryptMiddleware::class, $middlewares[1]);
107+
self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[2]);
108+
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[3]);
109+
}
110+
}

0 commit comments

Comments
 (0)