Skip to content

Commit 8516bba

Browse files
authored
Merge pull request #821 from patchlevel/command-bus-improvements
allow multiple handlers, union types and inheritance for commands
2 parents 8f4ecdb + 4c79ed5 commit 8516bba

11 files changed

Lines changed: 358 additions & 47 deletions

docs/pages/command_bus.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,59 @@ final class CreateProfileHandler
4545
!!! tip
4646

4747
A class can have multiple handle methods.
48+
49+
### Multiple Handle Attributes
50+
51+
A method can also have multiple `#[Handle]` attributes.
52+
This is useful if you want to handle different commands with the same method.
53+
54+
```php
55+
use Patchlevel\EventSourcing\Attribute\Handle;
56+
57+
final class CreateProfileHandler
58+
{
59+
#[Handle(CreateProfile::class)]
60+
#[Handle(UpdateProfile::class)]
61+
public function __invoke(object $command): void
62+
{
63+
// handle both commands
64+
}
65+
}
66+
```
67+
68+
### Union Types
69+
70+
You can also use union types to handle multiple commands and the library will automatically detect the commands.
71+
72+
```php
73+
use Patchlevel\EventSourcing\Attribute\Handle;
74+
75+
final class CreateProfileHandler
76+
{
77+
#[Handle]
78+
public function __invoke(CreateProfile|UpdateProfile $command): void
79+
{
80+
// handle both commands
81+
}
82+
}
83+
```
84+
85+
### Inheritance
86+
87+
The handler will also be invoked if the command implements an interface or extends a class that the handler expects.
88+
89+
```php
90+
use Patchlevel\EventSourcing\Attribute\Handle;
91+
92+
final class CreateProfileHandler
93+
{
94+
#[Handle]
95+
public function __invoke(CommandInterface $command): void
96+
{
97+
// handle all commands that implement CommandInterface
98+
}
99+
}
100+
```
48101

49102
### Aggregate Handler
50103

src/Attribute/Handle.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use Attribute;
88

9-
#[Attribute(Attribute::TARGET_METHOD)]
9+
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
1010
final class Handle
1111
{
1212
/** @param class-string|null $commandClass */

src/CommandBus/AggregateHandlerProvider.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
use Patchlevel\EventSourcing\Repository\RepositoryManager;
1212
use Psr\Container\ContainerInterface;
1313

14+
use function class_implements;
15+
use function class_parents;
16+
1417
final class AggregateHandlerProvider implements HandlerProvider
1518
{
1619
private bool $initialized = false;
@@ -36,7 +39,9 @@ public function handlerForCommand(string $commandClass): iterable
3639
$this->initialize();
3740
}
3841

39-
return $this->handlers[$commandClass] ?? [];
42+
foreach (self::resolveClasses($commandClass) as $class) {
43+
yield from $this->handlers[$class] ?? [];
44+
}
4045
}
4146

4247
private function initialize(): void
@@ -69,4 +74,19 @@ private function initialize(): void
6974

7075
$this->initialized = true;
7176
}
77+
78+
/**
79+
* @param class-string $class
80+
*
81+
* @return array<class-string, class-string>
82+
*/
83+
private static function resolveClasses(string $class): array
84+
{
85+
/** @var array<class-string, class-string> $classes */
86+
$classes = [$class => $class]
87+
+ class_parents($class)
88+
+ class_implements($class);
89+
90+
return $classes;
91+
}
7292
}

src/CommandBus/HandlerFinder.php

Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@
66

77
use Patchlevel\EventSourcing\Attribute\Handle;
88
use ReflectionClass;
9+
use ReflectionMethod;
910
use Symfony\Component\TypeInfo\Type\ObjectType;
11+
use Symfony\Component\TypeInfo\Type\UnionType;
1012
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
1113

1214
final class HandlerFinder
1315
{
16+
private static TypeResolver $typeResolver;
17+
1418
/**
1519
* @param class-string $classString
1620
*
1721
* @return iterable<int, HandlerReference>
1822
*/
1923
public static function findInClass(string $classString): iterable
2024
{
21-
$typeResolver = TypeResolver::create();
2225
$reflectionClass = new ReflectionClass($classString);
2326

2427
foreach ($reflectionClass->getMethods() as $reflectionMethod) {
@@ -28,53 +31,88 @@ public static function findInClass(string $classString): iterable
2831
continue;
2932
}
3033

31-
$handle = $handleAttributes[0]->newInstance();
34+
foreach ($handleAttributes as $attribute) {
35+
$handle = $attribute->newInstance();
36+
37+
if ($handle->commandClass !== null) {
38+
yield new HandlerReference(
39+
$handle->commandClass,
40+
$reflectionMethod->getName(),
41+
$reflectionMethod->isStatic(),
42+
);
43+
44+
continue;
45+
}
46+
47+
foreach (self::guessHandledClasses($reflectionMethod) as $class) {
48+
yield new HandlerReference(
49+
$class,
50+
$reflectionMethod->getName(),
51+
$reflectionMethod->isStatic(),
52+
);
53+
}
54+
}
55+
}
56+
}
3257

33-
if ($handle->commandClass !== null) {
34-
yield new HandlerReference(
35-
$handle->commandClass,
36-
$reflectionMethod->getName(),
37-
$reflectionMethod->isStatic(),
38-
);
58+
/** @return list<class-string> */
59+
private static function guessHandledClasses(ReflectionMethod $reflectionMethod): array
60+
{
61+
$parameters = $reflectionMethod->getParameters();
3962

40-
continue;
41-
}
63+
if ($parameters === []) {
64+
throw InvalidHandleMethod::noParameters(
65+
$reflectionMethod->getDeclaringClass()->getName(),
66+
$reflectionMethod->getName(),
67+
);
68+
}
4269

43-
$parameters = $reflectionMethod->getParameters();
70+
$reflectionType = $parameters[0]->getType();
4471

45-
if ($parameters === []) {
46-
throw InvalidHandleMethod::noParameters(
47-
$reflectionMethod->getDeclaringClass()->getName(),
48-
$reflectionMethod->getName(),
49-
);
50-
}
72+
if ($reflectionType === null) {
73+
throw InvalidHandleMethod::incompatibleType(
74+
$reflectionMethod->getDeclaringClass()->getName(),
75+
$reflectionMethod->getName(),
76+
);
77+
}
5178

52-
$reflectionType = $parameters[0]->getType();
79+
$type = self::typeResolver()->resolve($reflectionType);
5380

54-
if ($reflectionType === null) {
55-
throw InvalidHandleMethod::incompatibleType(
56-
$reflectionMethod->getDeclaringClass()->getName(),
57-
$reflectionMethod->getName(),
58-
);
59-
}
81+
if ($type instanceof ObjectType) {
82+
/** @var class-string $className */
83+
$className = $type->getClassName();
6084

61-
$type = $typeResolver->resolve($reflectionType);
85+
return [$className];
86+
}
6287

63-
if (!$type instanceof ObjectType) {
64-
throw InvalidHandleMethod::incompatibleType(
65-
$reflectionMethod->getDeclaringClass()->getName(),
66-
$reflectionMethod->getName(),
67-
);
68-
}
88+
if ($type instanceof UnionType) {
89+
$types = [];
6990

70-
/** @var class-string $commandClass */
71-
$commandClass = $type->getClassName();
91+
foreach ($type->getTypes() as $unionType) {
92+
if (!$unionType instanceof ObjectType) {
93+
throw InvalidHandleMethod::incompatibleType(
94+
$reflectionMethod->getDeclaringClass()->getName(),
95+
$reflectionMethod->getName(),
96+
);
97+
}
7298

73-
yield new HandlerReference(
74-
$commandClass,
75-
$reflectionMethod->getName(),
76-
$reflectionMethod->isStatic(),
77-
);
99+
/** @var class-string $className */
100+
$className = $unionType->getClassName();
101+
102+
$types[] = $className;
103+
}
104+
105+
return $types;
78106
}
107+
108+
throw InvalidHandleMethod::incompatibleType(
109+
$reflectionMethod->getDeclaringClass()->getName(),
110+
$reflectionMethod->getName(),
111+
);
112+
}
113+
114+
private static function typeResolver(): TypeResolver
115+
{
116+
return self::$typeResolver ??= TypeResolver::create();
79117
}
80118
}

src/CommandBus/ServiceHandlerProvider.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace Patchlevel\EventSourcing\CommandBus;
66

7+
use function class_implements;
8+
use function class_parents;
9+
710
final class ServiceHandlerProvider implements HandlerProvider
811
{
912
private bool $initialized = false;
@@ -28,7 +31,9 @@ public function handlerForCommand(string $commandClass): iterable
2831
$this->initialize();
2932
}
3033

31-
return $this->handlers[$commandClass] ?? [];
34+
foreach (self::resolveClasses($commandClass) as $class) {
35+
yield from $this->handlers[$class] ?? [];
36+
}
3237
}
3338

3439
private function initialize(): void
@@ -51,4 +56,19 @@ private function initialize(): void
5156

5257
$this->initialized = true;
5358
}
59+
60+
/**
61+
* @param class-string $class
62+
*
63+
* @return array<class-string, class-string>
64+
*/
65+
private static function resolveClasses(string $class): array
66+
{
67+
/** @var array<class-string, class-string> $classes */
68+
$classes = [$class => $class]
69+
+ class_parents($class)
70+
+ class_implements($class);
71+
72+
return $classes;
73+
}
5474
}

tests/Unit/CommandBus/AggregateHandlerProviderTest.php

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
use Patchlevel\EventSourcing\CommandBus\Handler\UpdateAggregateHandler;
1111
use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry;
1212
use Patchlevel\EventSourcing\Repository\RepositoryManager;
13+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\BaseCommand;
1314
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ChangeProfileName;
1415
use Patchlevel\EventSourcing\Tests\Unit\Fixture\CreateProfile;
1516
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithHandler;
17+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithInheritanceHandler;
18+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\SomeCommand;
1619
use PHPUnit\Framework\Attributes\CoversClass;
1720
use PHPUnit\Framework\TestCase;
1821

@@ -28,7 +31,7 @@ public function testEmpty(): void
2831
$repositoryManager,
2932
);
3033

31-
$result = $provider->handlerForCommand(CreateProfile::class);
34+
$result = [...$provider->handlerForCommand(CreateProfile::class)];
3235

3336
self::assertCount(0, $result);
3437
}
@@ -42,7 +45,7 @@ public function testGetCreateHandler(): void
4245
$repositoryManager,
4346
);
4447

45-
$result = $provider->handlerForCommand(CreateProfile::class);
48+
$result = [...$provider->handlerForCommand(CreateProfile::class)];
4649

4750
$handler = new CreateAggregateHandler(
4851
$repositoryManager,
@@ -64,7 +67,7 @@ public function testGetUpdateHandler(): void
6467
$repositoryManager,
6568
);
6669

67-
$result = $provider->handlerForCommand(ChangeProfileName::class);
70+
$result = [...$provider->handlerForCommand(ChangeProfileName::class)];
6871

6972
$handler = new UpdateAggregateHandler(
7073
$repositoryManager,
@@ -76,4 +79,52 @@ public function testGetUpdateHandler(): void
7679
self::assertCount(1, $result);
7780
self::assertEquals($handler->__invoke(...), $result[0]->callable());
7881
}
82+
83+
public function testGetHandlerByInterface(): void
84+
{
85+
$repositoryManager = $this->createMock(RepositoryManager::class);
86+
$command = new class () implements SomeCommand {
87+
};
88+
89+
$provider = new AggregateHandlerProvider(
90+
new AggregateRootRegistry(['profile' => ProfileWithInheritanceHandler::class]),
91+
$repositoryManager,
92+
);
93+
94+
$result = [...$provider->handlerForCommand($command::class)];
95+
96+
$handler = new UpdateAggregateHandler(
97+
$repositoryManager,
98+
ProfileWithInheritanceHandler::class,
99+
'handleInterface',
100+
new DefaultParameterResolver(),
101+
);
102+
103+
self::assertCount(1, $result);
104+
self::assertEquals($handler->__invoke(...), $result[0]->callable());
105+
}
106+
107+
public function testGetHandlerByAbstractClass(): void
108+
{
109+
$repositoryManager = $this->createMock(RepositoryManager::class);
110+
$command = new class () extends BaseCommand {
111+
};
112+
113+
$provider = new AggregateHandlerProvider(
114+
new AggregateRootRegistry(['profile' => ProfileWithInheritanceHandler::class]),
115+
$repositoryManager,
116+
);
117+
118+
$result = [...$provider->handlerForCommand($command::class)];
119+
120+
$handler = new UpdateAggregateHandler(
121+
$repositoryManager,
122+
ProfileWithInheritanceHandler::class,
123+
'handleAbstract',
124+
new DefaultParameterResolver(),
125+
);
126+
127+
self::assertCount(1, $result);
128+
self::assertEquals($handler->__invoke(...), $result[0]->callable());
129+
}
79130
}

0 commit comments

Comments
 (0)