Skip to content

Commit 9c90185

Browse files
authored
Merge pull request #152 from patchlevel/lifecycle-extension
add lifecycle extension
2 parents 2ee7bcf + ff133f5 commit 9c90185

12 files changed

Lines changed: 570 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD)]
10+
final class PostExtract
11+
{
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD)]
10+
final class PostHydrate
11+
{
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD)]
10+
final class PreExtract
11+
{
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD)]
10+
final class PreHydrate
11+
{
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle;
6+
7+
final readonly class Lifecycle
8+
{
9+
public function __construct(
10+
public string|null $preHydrate = null,
11+
public string|null $postHydrate = null,
12+
public string|null $preExtract = null,
13+
public string|null $postExtract = null,
14+
) {
15+
}
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle;
6+
7+
use Patchlevel\Hydrator\Extension;
8+
use Patchlevel\Hydrator\HydratorBuilder;
9+
10+
final readonly class LifecycleExtension implements Extension
11+
{
12+
public function configure(HydratorBuilder $builder): void
13+
{
14+
$builder->addMiddleware(new LifecycleMiddleware());
15+
$builder->addMetadataEnricher(new LifecycleMetadataEnricher());
16+
}
17+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle;
6+
7+
use LogicException;
8+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostExtract;
9+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate;
10+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract;
11+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreHydrate;
12+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
13+
use Patchlevel\Hydrator\Metadata\MetadataEnricher;
14+
15+
use function sprintf;
16+
17+
final class LifecycleMetadataEnricher implements MetadataEnricher
18+
{
19+
public function enrich(ClassMetadata $classMetadata): void
20+
{
21+
$preHydrate = null;
22+
$postHydrate = null;
23+
$preExtract = null;
24+
$postExtract = null;
25+
26+
foreach ($classMetadata->reflection->getMethods() as $reflectionMethod) {
27+
if ($reflectionMethod->getAttributes(PreHydrate::class)) {
28+
if ($reflectionMethod->isStatic() === false) {
29+
throw new LogicException(sprintf('Method "%s::%s" must be static when using the PreHydrate attribute.', $classMetadata->className, $reflectionMethod->getName()));
30+
}
31+
32+
$preHydrate = $reflectionMethod->getName();
33+
}
34+
35+
if ($reflectionMethod->getAttributes(PostHydrate::class)) {
36+
if ($reflectionMethod->isStatic() === false) {
37+
throw new LogicException(sprintf('Method "%s::%s" must be static when using the PostHydrate attribute.', $classMetadata->className, $reflectionMethod->getName()));
38+
}
39+
40+
$postHydrate = $reflectionMethod->getName();
41+
}
42+
43+
if ($reflectionMethod->getAttributes(PreExtract::class)) {
44+
if ($reflectionMethod->isStatic() === false) {
45+
throw new LogicException(sprintf('Method "%s::%s" must be static when using the PreExtract attribute.', $classMetadata->className, $reflectionMethod->getName()));
46+
}
47+
48+
$preExtract = $reflectionMethod->getName();
49+
}
50+
51+
if (!$reflectionMethod->getAttributes(PostExtract::class)) {
52+
continue;
53+
}
54+
55+
if ($reflectionMethod->isStatic() === false) {
56+
throw new LogicException(sprintf('Method "%s::%s" must be static when using the PostExtract attribute.', $classMetadata->className, $reflectionMethod->getName()));
57+
}
58+
59+
$postExtract = $reflectionMethod->getName();
60+
}
61+
62+
if ($preHydrate === null && $postHydrate === null && $preExtract === null && $postExtract === null) {
63+
return;
64+
}
65+
66+
$classMetadata->extras[Lifecycle::class] = new Lifecycle(
67+
$preHydrate,
68+
$postHydrate,
69+
$preExtract,
70+
$postExtract,
71+
);
72+
}
73+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Lifecycle;
6+
7+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
8+
use Patchlevel\Hydrator\Middleware\Middleware;
9+
use Patchlevel\Hydrator\Middleware\Stack;
10+
11+
use function assert;
12+
13+
final class LifecycleMiddleware implements Middleware
14+
{
15+
/**
16+
* @param ClassMetadata<T> $metadata
17+
* @param array<string, mixed> $data
18+
* @param array<string, mixed> $context
19+
*
20+
* @return T
21+
*
22+
* @template T of object
23+
*/
24+
public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
25+
{
26+
$lifecycle = $metadata->extras[Lifecycle::class] ?? null;
27+
assert($lifecycle instanceof Lifecycle || $lifecycle === null);
28+
29+
if ($lifecycle?->preHydrate) {
30+
$data = $metadata->reflection->getMethod($lifecycle->preHydrate)->invoke(null, $data, $context);
31+
/** @var array<string, mixed> $data */
32+
}
33+
34+
$object = $stack->next()->hydrate($metadata, $data, $context, $stack);
35+
36+
if ($lifecycle?->postHydrate) {
37+
$metadata->reflection->getMethod($lifecycle->postHydrate)->invoke(null, $object, $context);
38+
}
39+
40+
return $object;
41+
}
42+
43+
/**
44+
* @param ClassMetadata<T> $metadata
45+
* @param T $object
46+
* @param array<string, mixed> $context
47+
*
48+
* @return array<string, mixed>
49+
*
50+
* @template T of object
51+
*/
52+
public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array
53+
{
54+
$lifecycle = $metadata->extras[Lifecycle::class] ?? null;
55+
assert($lifecycle instanceof Lifecycle || $lifecycle === null);
56+
57+
if ($lifecycle?->preExtract) {
58+
$metadata->reflection->getMethod($lifecycle->preExtract)->invoke(null, $object, $context);
59+
}
60+
61+
$data = $stack->next()->extract($metadata, $object, $context, $stack);
62+
63+
if ($lifecycle?->postExtract) {
64+
$data = $metadata->reflection->getMethod($lifecycle->postExtract)->invoke(null, $data, $context);
65+
/** @var array<string, mixed> $data */
66+
}
67+
68+
return $data;
69+
}
70+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle;
6+
7+
use Patchlevel\Hydrator\CoreExtension;
8+
use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension;
9+
use Patchlevel\Hydrator\HydratorBuilder;
10+
use Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\TestCase;
13+
14+
#[CoversClass(LifecycleExtension::class)]
15+
final class LifecycleExtensionTest extends TestCase
16+
{
17+
public function testIntegration(): void
18+
{
19+
$hydrator = (new HydratorBuilder())
20+
->useExtension(new CoreExtension())
21+
->useExtension(new LifecycleExtension())
22+
->build();
23+
24+
$data = ['name' => 'foo'];
25+
$object = $hydrator->hydrate(LifecycleFixture::class, $data);
26+
27+
self::assertSame('foo [preHydrate] [postHydrate]', $object->name);
28+
29+
$extractedData = $hydrator->extract($object);
30+
31+
self::assertSame('foo [preHydrate] [postHydrate] [preExtract] [postExtract]', $extractedData['name']);
32+
}
33+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle;
6+
7+
use LogicException;
8+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostExtract;
9+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate;
10+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract;
11+
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreHydrate;
12+
use Patchlevel\Hydrator\Extension\Lifecycle\Lifecycle;
13+
use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleMetadataEnricher;
14+
use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory;
15+
use Patchlevel\Hydrator\Metadata\ClassMetadata;
16+
use Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture;
17+
use PHPUnit\Framework\Attributes\CoversClass;
18+
use PHPUnit\Framework\TestCase;
19+
20+
#[CoversClass(LifecycleMetadataEnricher::class)]
21+
final class LifecycleMetadataEnricherTest extends TestCase
22+
{
23+
public function testEnrich(): void
24+
{
25+
$metadata = $this->metadata(LifecycleFixture::class);
26+
27+
self::assertArrayHasKey(Lifecycle::class, $metadata->extras);
28+
$lifecycle = $metadata->extras[Lifecycle::class];
29+
30+
self::assertInstanceOf(Lifecycle::class, $lifecycle);
31+
self::assertSame('preHydrate', $lifecycle->preHydrate);
32+
self::assertSame('postHydrate', $lifecycle->postHydrate);
33+
self::assertSame('preExtract', $lifecycle->preExtract);
34+
self::assertSame('postExtract', $lifecycle->postExtract);
35+
}
36+
37+
public function testNoLifecycleAttributes(): void
38+
{
39+
$object = new class {
40+
};
41+
42+
$metadata = $this->metadata($object::class);
43+
44+
self::assertArrayNotHasKey(Lifecycle::class, $metadata->extras);
45+
}
46+
47+
public function testNonStaticPreHydrate(): void
48+
{
49+
$object = new class {
50+
#[PreHydrate]
51+
public function preHydrate(): void
52+
{
53+
}
54+
};
55+
56+
$this->expectException(LogicException::class);
57+
$this->metadata($object::class);
58+
}
59+
60+
public function testNonStaticPostHydrate(): void
61+
{
62+
$object = new class {
63+
#[PostHydrate]
64+
public function postHydrate(): void
65+
{
66+
}
67+
};
68+
69+
$this->expectException(LogicException::class);
70+
$this->metadata($object::class);
71+
}
72+
73+
public function testNonStaticPreExtract(): void
74+
{
75+
$object = new class {
76+
#[PreExtract]
77+
public function preExtract(): void
78+
{
79+
}
80+
};
81+
82+
$this->expectException(LogicException::class);
83+
$this->metadata($object::class);
84+
}
85+
86+
public function testNonStaticPostExtract(): void
87+
{
88+
$object = new class {
89+
#[PostExtract]
90+
public function postExtract(): void
91+
{
92+
}
93+
};
94+
95+
$this->expectException(LogicException::class);
96+
$this->metadata($object::class);
97+
}
98+
99+
/** @param class-string $class */
100+
private function metadata(string $class): ClassMetadata
101+
{
102+
$metadata = (new AttributeMetadataFactory())->metadata($class);
103+
(new LifecycleMetadataEnricher())->enrich($metadata);
104+
105+
return $metadata;
106+
}
107+
}

0 commit comments

Comments
 (0)