Skip to content

Commit 9b85ab4

Browse files
authored
Using messenger HandleTrait as QueryBus with appropriate result typing
1 parent 3783cc7 commit 9b85ab4

File tree

7 files changed

+342
-15
lines changed

7 files changed

+342
-15
lines changed

README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ This extension provides following features:
2929
* Provides correct return type for `Extension::getConfiguration()` method.
3030
* Provides correct return type for `CacheInterface::get()` method based on the callback return type.
3131
* Provides correct return type for `BrowserKitAssertionsTrait::getClient()` method.
32+
* Provides configurable return type resolution for methods that internally use Messenger `HandleTrait`.
3233
* Notifies you when you try to get an unregistered service from the container.
3334
* Notifies you when you try to get a private service from the container.
3435
* Notifies you when you access undefined console command arguments or options.
@@ -180,3 +181,95 @@ Call the new env in your `console-application.php`:
180181
```php
181182
$kernel = new \App\Kernel('phpstan_env', (bool) $_SERVER['APP_DEBUG']);
182183
```
184+
185+
## Messenger HandleTrait Wrappers
186+
187+
The extension provides advanced type inference for methods that internally use Symfony Messenger's `HandleTrait`. This feature is particularly useful for query bus implementations (in CQRS pattern) that use/wrap the `HandleTrait::handle()` method.
188+
189+
### Configuration
190+
191+
```neon
192+
parameters:
193+
symfony:
194+
messenger:
195+
handleTraitWrappers:
196+
- App\Bus\QueryBus::dispatch
197+
- App\Bus\QueryBus::execute
198+
- App\Bus\QueryBusInterface::dispatch
199+
```
200+
201+
### Message Handlers
202+
203+
```php
204+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
205+
206+
// Product handler that returns Product
207+
#[AsMessageHandler]
208+
class GetProductQueryHandler
209+
{
210+
public function __invoke(GetProductQuery $query): Product
211+
{
212+
return $this->productRepository->get($query->productId);
213+
}
214+
}
215+
```
216+
217+
### PHP Examples
218+
219+
```php
220+
use Symfony\Component\Messenger\HandleTrait;
221+
use Symfony\Component\Messenger\MessageBusInterface;
222+
223+
// Basic query bus implementation
224+
class QueryBus
225+
{
226+
use HandleTrait;
227+
228+
public function __construct(MessageBusInterface $messageBus)
229+
{
230+
$this->messageBus = $messageBus;
231+
}
232+
233+
public function dispatch(object $query): mixed
234+
{
235+
return $this->handle($query); // Return type will be inferred in calling code as query result
236+
}
237+
238+
// Multiple methods per class example
239+
public function execute(object $message): mixed
240+
{
241+
return $this->handle($message); // Return type will be inferred in calling code as query result
242+
}
243+
}
244+
245+
// Interface-based configuration example
246+
interface QueryBusInterface
247+
{
248+
public function dispatch(object $query): mixed; // Return type will be inferred in calling code as query result
249+
}
250+
251+
class QueryBusWithInterface implements QueryBusInterface
252+
{
253+
use HandleTrait;
254+
255+
public function __construct(MessageBusInterface $queryBus)
256+
{
257+
$this->messageBus = $queryBus;
258+
}
259+
260+
public function dispatch(object $query): mixed
261+
{
262+
return $this->handle($query);
263+
}
264+
}
265+
266+
// Examples of use with proper type inference
267+
$query = new GetProductQuery($productId);
268+
$queryBus = new QueryBus($messageBus);
269+
$queryBusWithInterface = new QueryBusWithInterface($messageBus);
270+
271+
$product = $queryBus->dispatch($query); // Returns: Product
272+
$product2 = $queryBus->execute($query); // Returns: Product
273+
$product3 = $queryBusWithInterface->dispatch($query); // Returns: Product
274+
// Without the feature all above query bus results would be default 'mixed'.
275+
```

extension.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ parameters:
88
containerXmlPath: null
99
constantHassers: true
1010
consoleApplicationLoader: null
11+
messenger:
12+
handleTraitWrappers: []
1113
stubFiles:
1214
- stubs/Psr/Cache/CacheException.stub
1315
- stubs/Psr/Cache/CacheItemInterface.stub
@@ -96,6 +98,9 @@ parametersSchema:
9698
containerXmlPath: schema(string(), nullable())
9799
constantHassers: bool()
98100
consoleApplicationLoader: schema(string(), nullable())
101+
messenger: structure([
102+
handleTraitWrappers: listOf(string())
103+
])
99104
])
100105

101106
services:
@@ -203,6 +208,13 @@ services:
203208
class: PHPStan\Type\Symfony\MessengerHandleTraitReturnTypeExtension
204209
tags: [phpstan.broker.expressionTypeResolverExtension]
205210

211+
# Messenger HandleTrait wrappers return type
212+
-
213+
class: PHPStan\Type\Symfony\MessengerHandleTraitWrapperReturnTypeExtension
214+
tags: [phpstan.broker.expressionTypeResolverExtension]
215+
arguments:
216+
messenger: %symfony.messenger%
217+
206218
# InputInterface::getArgument() return type
207219
-
208220
factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension

src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Symfony\MessageMapFactory;
1111
use PHPStan\Type\ExpressionTypeResolverExtension;
1212
use PHPStan\Type\Type;
13+
use PHPStan\Type\TypeCombinator;
1314
use function count;
1415
use function is_null;
1516

@@ -30,26 +31,36 @@ public function __construct(MessageMapFactory $symfonyMessageMapFactory)
3031

3132
public function getType(Expr $expr, Scope $scope): ?Type
3233
{
33-
if ($this->isSupported($expr, $scope)) {
34-
$args = $expr->getArgs();
35-
if (count($args) !== 1) {
36-
return null;
37-
}
34+
if (!$this->isSupported($expr, $scope)) {
35+
return null;
36+
}
37+
38+
$args = $expr->getArgs();
39+
if (count($args) !== 1) {
40+
return null;
41+
}
3842

39-
$arg = $args[0]->value;
40-
$argClassNames = $scope->getType($arg)->getObjectClassNames();
43+
$arg = $args[0]->value;
44+
$argClassNames = $scope->getType($arg)->getObjectClassNames();
4145

42-
if (count($argClassNames) === 1) {
43-
$messageMap = $this->getMessageMap();
44-
$returnType = $messageMap->getTypeForClass($argClassNames[0]);
46+
if (count($argClassNames) === 0) {
47+
return null;
48+
}
49+
50+
$messageMap = $this->getMessageMap();
51+
52+
$returnTypes = [];
53+
foreach ($argClassNames as $argClassName) {
54+
$returnType = $messageMap->getTypeForClass($argClassName);
4555

46-
if (!is_null($returnType)) {
47-
return $returnType;
48-
}
56+
if (is_null($returnType)) {
57+
return null;
4958
}
59+
60+
$returnTypes[] = $returnType;
5061
}
5162

52-
return null;
63+
return TypeCombinator::union(...$returnTypes);
5364
}
5465

5566
private function getMessageMap(): MessageMap
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Symfony;
4+
5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PhpParser\Node\Identifier;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Symfony\MessageMap;
11+
use PHPStan\Symfony\MessageMapFactory;
12+
use PHPStan\Type\ExpressionTypeResolverExtension;
13+
use PHPStan\Type\Type;
14+
use PHPStan\Type\TypeCombinator;
15+
use function count;
16+
use function in_array;
17+
use function is_null;
18+
19+
/**
20+
* Configurable extension for resolving return types of methods that internally use HandleTrait.
21+
*
22+
* Configured via PHPStan parameters under symfony.messenger.handleTraitWrappers with
23+
* "class::method" patterns, e.g.:
24+
* - App\Bus\QueryBus::dispatch
25+
* - App\Bus\QueryBus::query
26+
* - App\Bus\CommandBus::execute
27+
* - App\Bus\CommandBus::handle
28+
*/
29+
final class MessengerHandleTraitWrapperReturnTypeExtension implements ExpressionTypeResolverExtension
30+
{
31+
32+
private MessageMapFactory $messageMapFactory;
33+
34+
private ?MessageMap $messageMap = null;
35+
36+
/** @var array<string> */
37+
private array $wrappers;
38+
39+
private ReflectionProvider $reflectionProvider;
40+
41+
/** @param array{handleTraitWrappers: array<string>}|null $messenger */
42+
public function __construct(MessageMapFactory $messageMapFactory, ?array $messenger, ReflectionProvider $reflectionProvider)
43+
{
44+
$this->messageMapFactory = $messageMapFactory;
45+
$this->wrappers = $messenger['handleTraitWrappers'] ?? [];
46+
$this->reflectionProvider = $reflectionProvider;
47+
}
48+
49+
public function getType(Expr $expr, Scope $scope): ?Type
50+
{
51+
if (!$this->isSupported($expr, $scope)) {
52+
return null;
53+
}
54+
55+
$args = $expr->getArgs();
56+
if (count($args) !== 1) {
57+
return null;
58+
}
59+
60+
$arg = $args[0]->value;
61+
$argClassNames = $scope->getType($arg)->getObjectClassNames();
62+
63+
if (count($argClassNames) === 0) {
64+
return null;
65+
}
66+
67+
$returnTypes = [];
68+
foreach ($argClassNames as $argClassName) {
69+
$messageMap = $this->getMessageMap();
70+
$returnType = $messageMap->getTypeForClass($argClassName);
71+
72+
if (is_null($returnType)) {
73+
return null;
74+
}
75+
76+
$returnTypes[] = $returnType;
77+
}
78+
79+
return TypeCombinator::union(...$returnTypes);
80+
}
81+
82+
/**
83+
* @phpstan-assert-if-true =MethodCall $expr
84+
*/
85+
private function isSupported(Expr $expr, Scope $scope): bool
86+
{
87+
if ($this->wrappers === []) {
88+
return false;
89+
}
90+
91+
if (!($expr instanceof MethodCall) || !($expr->name instanceof Identifier)) {
92+
return false;
93+
}
94+
95+
$methodName = $expr->name->name;
96+
$varType = $scope->getType($expr->var);
97+
$classNames = $varType->getObjectClassNames();
98+
99+
if (count($classNames) === 0) {
100+
return false;
101+
}
102+
103+
foreach ($classNames as $className) {
104+
if (!$this->isClassMethodSupported($className, $methodName)) {
105+
return false;
106+
}
107+
}
108+
109+
return true;
110+
}
111+
112+
private function isClassMethodSupported(string $className, string $methodName): bool
113+
{
114+
$classMethodCombination = $className . '::' . $methodName;
115+
116+
// Check if this exact class::method combination is configured
117+
if (in_array($classMethodCombination, $this->wrappers, true)) {
118+
return true;
119+
}
120+
121+
// Check if any interface implemented by this class::method is configured
122+
if ($this->reflectionProvider->hasClass($className)) {
123+
$classReflection = $this->reflectionProvider->getClass($className);
124+
foreach ($classReflection->getInterfaces() as $interface) {
125+
$interfaceMethodCombination = $interface->getName() . '::' . $methodName;
126+
if (in_array($interfaceMethodCombination, $this->wrappers, true)) {
127+
return true;
128+
}
129+
}
130+
}
131+
132+
return false;
133+
}
134+
135+
private function getMessageMap(): MessageMap
136+
{
137+
if ($this->messageMap === null) {
138+
$this->messageMap = $this->messageMapFactory->create();
139+
}
140+
141+
return $this->messageMap;
142+
}
143+
144+
}

0 commit comments

Comments
 (0)