Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit dd1aaa7

Browse files
authoredJan 4, 2025··
Support for Messenger HandleTrait return types
1 parent c7b7e7f commit dd1aaa7

12 files changed

+483
-2
lines changed
 

‎extension.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ services:
140140
-
141141
factory: @symfony.parameterMapFactory::create()
142142

143+
# message map
144+
symfony.messageMapFactory:
145+
class: PHPStan\Symfony\MessageMapFactory
146+
factory: PHPStan\Symfony\MessageMapFactory
147+
-
148+
factory: @symfony.messageMapFactory::create()
149+
143150
# ControllerTrait::get()/has() return type
144151
-
145152
factory: PHPStan\Type\Symfony\ServiceDynamicReturnTypeExtension(Symfony\Component\DependencyInjection\ContainerInterface)
@@ -203,6 +210,11 @@ services:
203210
factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension
204211
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
205212

213+
# Messenger HandleTrait::handle() return type
214+
-
215+
class: PHPStan\Type\Symfony\MessengerHandleTraitReturnTypeExtension
216+
tags: [phpstan.broker.expressionTypeResolverExtension]
217+
206218
# InputInterface::getArgument() return type
207219
-
208220
factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension

‎src/Symfony/MessageMap.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Symfony;
4+
5+
use PHPStan\Type\Type;
6+
7+
final class MessageMap
8+
{
9+
10+
/** @var array<string, Type> */
11+
private $messageMap;
12+
13+
/** @param array<string, Type> $messageMap */
14+
public function __construct(array $messageMap)
15+
{
16+
$this->messageMap = $messageMap;
17+
}
18+
19+
public function getTypeForClass(string $class): ?Type
20+
{
21+
return $this->messageMap[$class] ?? null;
22+
}
23+
24+
}

‎src/Symfony/MessageMapFactory.php

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Symfony;
4+
5+
use PHPStan\Reflection\ClassReflection;
6+
use PHPStan\Reflection\ReflectionProvider;
7+
use Symfony\Component\Messenger\Handler\MessageSubscriberInterface;
8+
use function class_exists;
9+
use function count;
10+
use function is_array;
11+
use function is_int;
12+
use function is_string;
13+
14+
final class MessageMapFactory
15+
{
16+
17+
private const MESSENGER_HANDLER_TAG = 'messenger.message_handler';
18+
private const DEFAULT_HANDLER_METHOD = '__invoke';
19+
20+
/** @var ReflectionProvider */
21+
private $reflectionProvider;
22+
23+
/** @var ServiceMap */
24+
private $serviceMap;
25+
26+
public function __construct(ServiceMap $symfonyServiceMap, ReflectionProvider $reflectionProvider)
27+
{
28+
$this->serviceMap = $symfonyServiceMap;
29+
$this->reflectionProvider = $reflectionProvider;
30+
}
31+
32+
public function create(): MessageMap
33+
{
34+
$returnTypesMap = [];
35+
36+
foreach ($this->serviceMap->getServices() as $service) {
37+
$serviceClass = $service->getClass();
38+
39+
if ($serviceClass === null) {
40+
continue;
41+
}
42+
43+
foreach ($service->getTags() as $tag) {
44+
if ($tag->getName() !== self::MESSENGER_HANDLER_TAG) {
45+
continue;
46+
}
47+
48+
if (!$this->reflectionProvider->hasClass($serviceClass)) {
49+
continue;
50+
}
51+
52+
$reflectionClass = $this->reflectionProvider->getClass($serviceClass);
53+
54+
/** @var array{handles?: class-string, method?: string} $tagAttributes */
55+
$tagAttributes = $tag->getAttributes();
56+
57+
if (isset($tagAttributes['handles'])) {
58+
$handles = [$tagAttributes['handles'] => ['method' => $tagAttributes['method'] ?? self::DEFAULT_HANDLER_METHOD]];
59+
} else {
60+
$handles = $this->guessHandledMessages($reflectionClass);
61+
}
62+
63+
foreach ($handles as $messageClassName => $options) {
64+
$methodName = $options['method'] ?? self::DEFAULT_HANDLER_METHOD;
65+
66+
if (!$reflectionClass->hasNativeMethod($methodName)) {
67+
continue;
68+
}
69+
70+
$methodReflection = $reflectionClass->getNativeMethod($methodName);
71+
72+
foreach ($methodReflection->getVariants() as $variant) {
73+
$returnTypesMap[$messageClassName][] = $variant->getReturnType();
74+
}
75+
}
76+
}
77+
}
78+
79+
$messageMap = [];
80+
foreach ($returnTypesMap as $messageClassName => $returnTypes) {
81+
if (count($returnTypes) !== 1) {
82+
continue;
83+
}
84+
85+
$messageMap[$messageClassName] = $returnTypes[0];
86+
}
87+
88+
return new MessageMap($messageMap);
89+
}
90+
91+
/** @return iterable<string, array<string, string>> */
92+
private function guessHandledMessages(ClassReflection $reflectionClass): iterable
93+
{
94+
if ($reflectionClass->implementsInterface(MessageSubscriberInterface::class)) {
95+
$className = $reflectionClass->getName();
96+
97+
foreach ($className::getHandledMessages() as $index => $value) {
98+
$containOptions = self::containOptions($index, $value);
99+
if ($containOptions === true) {
100+
yield $index => $value;
101+
} elseif ($containOptions === false) {
102+
yield $value => ['method' => self::DEFAULT_HANDLER_METHOD];
103+
}
104+
}
105+
106+
return;
107+
}
108+
109+
if (!$reflectionClass->hasNativeMethod(self::DEFAULT_HANDLER_METHOD)) {
110+
return;
111+
}
112+
113+
$methodReflection = $reflectionClass->getNativeMethod(self::DEFAULT_HANDLER_METHOD);
114+
115+
$variants = $methodReflection->getVariants();
116+
if (count($variants) !== 1) {
117+
return;
118+
}
119+
120+
$parameters = $variants[0]->getParameters();
121+
122+
if (count($parameters) !== 1) {
123+
return;
124+
}
125+
126+
$classNames = $parameters[0]->getType()->getObjectClassNames();
127+
128+
if (count($classNames) !== 1) {
129+
return;
130+
}
131+
132+
yield $classNames[0] => ['method' => self::DEFAULT_HANDLER_METHOD];
133+
}
134+
135+
/**
136+
* @param mixed $index
137+
* @param mixed $value
138+
* @phpstan-assert-if-true =class-string $index
139+
* @phpstan-assert-if-true =array<string, mixed> $value
140+
* @phpstan-assert-if-false =int $index
141+
* @phpstan-assert-if-false =class-string $value
142+
*/
143+
private static function containOptions($index, $value): ?bool
144+
{
145+
if (is_string($index) && class_exists($index) && is_array($value)) {
146+
return true;
147+
} elseif (is_int($index) && is_string($value) && class_exists($value)) {
148+
return false;
149+
}
150+
151+
return null;
152+
}
153+
154+
}

‎src/Symfony/Service.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,25 @@ final class Service implements ServiceDefinition
2020
/** @var string|null */
2121
private $alias;
2222

23+
/** @var ServiceTag[] */
24+
private $tags;
25+
26+
/** @param ServiceTag[] $tags */
2327
public function __construct(
2428
string $id,
2529
?string $class,
2630
bool $public,
2731
bool $synthetic,
28-
?string $alias
32+
?string $alias,
33+
array $tags = []
2934
)
3035
{
3136
$this->id = $id;
3237
$this->class = $class;
3338
$this->public = $public;
3439
$this->synthetic = $synthetic;
3540
$this->alias = $alias;
41+
$this->tags = $tags;
3642
}
3743

3844
public function getId(): string
@@ -60,4 +66,9 @@ public function getAlias(): ?string
6066
return $this->alias;
6167
}
6268

69+
public function getTags(): array
70+
{
71+
return $this->tags;
72+
}
73+
6374
}

‎src/Symfony/ServiceDefinition.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ public function isSynthetic(): bool;
1818

1919
public function getAlias(): ?string;
2020

21+
/** @return ServiceTag[] */
22+
public function getTags(): array;
23+
2124
}

‎src/Symfony/ServiceTag.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Symfony;
4+
5+
final class ServiceTag implements ServiceTagDefinition
6+
{
7+
8+
/** @var string */
9+
private $name;
10+
11+
/** @var array<string, string> */
12+
private $attributes;
13+
14+
/** @param array<string, string> $attributes */
15+
public function __construct(string $name, array $attributes = [])
16+
{
17+
$this->name = $name;
18+
$this->attributes = $attributes;
19+
}
20+
21+
public function getName(): string
22+
{
23+
return $this->name;
24+
}
25+
26+
public function getAttributes(): array
27+
{
28+
return $this->attributes;
29+
}
30+
31+
}

‎src/Symfony/ServiceTagDefinition.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Symfony;
4+
5+
interface ServiceTagDefinition
6+
{
7+
8+
public function getName(): string;
9+
10+
/** @return array<string, string> */
11+
public function getAttributes(): array;
12+
13+
}

‎src/Symfony/XmlServiceMapFactory.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,22 @@ public function create(): ServiceMap
4747
continue;
4848
}
4949

50+
$serviceTags = [];
51+
foreach ($def->tag as $tag) {
52+
$tagAttrs = ((array) $tag->attributes())['@attributes'] ?? [];
53+
$tagName = $tagAttrs['name'];
54+
unset($tagAttrs['name']);
55+
56+
$serviceTags[] = new ServiceTag($tagName, $tagAttrs);
57+
}
58+
5059
$service = new Service(
5160
$this->cleanServiceId((string) $attrs->id),
5261
isset($attrs->class) ? (string) $attrs->class : null,
5362
isset($attrs->public) && (string) $attrs->public === 'true',
5463
isset($attrs->synthetic) && (string) $attrs->synthetic === 'true',
55-
isset($attrs->alias) ? $this->cleanServiceId((string) $attrs->alias) : null
64+
isset($attrs->alias) ? $this->cleanServiceId((string) $attrs->alias) : null,
65+
$serviceTags
5666
);
5767

5868
if ($service->getAlias() !== null) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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\Symfony\MessageMap;
10+
use PHPStan\Symfony\MessageMapFactory;
11+
use PHPStan\Type\ExpressionTypeResolverExtension;
12+
use PHPStan\Type\Type;
13+
use function count;
14+
use function is_null;
15+
16+
final class MessengerHandleTraitReturnTypeExtension implements ExpressionTypeResolverExtension
17+
{
18+
19+
private const TRAIT_NAME = 'Symfony\Component\Messenger\HandleTrait';
20+
private const TRAIT_METHOD_NAME = 'handle';
21+
22+
/** @var MessageMapFactory */
23+
private $messageMapFactory;
24+
25+
/** @var MessageMap|null */
26+
private $messageMap;
27+
28+
public function __construct(MessageMapFactory $symfonyMessageMapFactory)
29+
{
30+
$this->messageMapFactory = $symfonyMessageMapFactory;
31+
}
32+
33+
public function getType(Expr $expr, Scope $scope): ?Type
34+
{
35+
if ($this->isSupported($expr, $scope)) {
36+
$args = $expr->getArgs();
37+
if (count($args) !== 1) {
38+
return null;
39+
}
40+
41+
$arg = $args[0]->value;
42+
$argClassNames = $scope->getType($arg)->getObjectClassNames();
43+
44+
if (count($argClassNames) === 1) {
45+
$messageMap = $this->getMessageMap();
46+
$returnType = $messageMap->getTypeForClass($argClassNames[0]);
47+
48+
if (!is_null($returnType)) {
49+
return $returnType;
50+
}
51+
}
52+
}
53+
54+
return null;
55+
}
56+
57+
private function getMessageMap(): MessageMap
58+
{
59+
if ($this->messageMap === null) {
60+
$this->messageMap = $this->messageMapFactory->create();
61+
}
62+
63+
return $this->messageMap;
64+
}
65+
66+
/**
67+
* @phpstan-assert-if-true =MethodCall $expr
68+
*/
69+
private function isSupported(Expr $expr, Scope $scope): bool
70+
{
71+
if (!($expr instanceof MethodCall) || !($expr->name instanceof Identifier) || $expr->name->name !== self::TRAIT_METHOD_NAME) {
72+
return false;
73+
}
74+
75+
if (!$scope->isInClass()) {
76+
return false;
77+
}
78+
79+
$reflectionClass = $scope->getClassReflection()->getNativeReflection();
80+
81+
if (!$reflectionClass->hasMethod(self::TRAIT_METHOD_NAME)) {
82+
return false;
83+
}
84+
85+
$methodReflection = $reflectionClass->getMethod(self::TRAIT_METHOD_NAME);
86+
$declaringClassReflection = $methodReflection->getBetterReflection()->getDeclaringClass();
87+
88+
return $declaringClassReflection->getName() === self::TRAIT_NAME;
89+
}
90+
91+
}

‎tests/Type/Symfony/ExtensionTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ExtensionTest extends TypeInferenceTestCase
1414
/** @return mixed[] */
1515
public function dataFileAsserts(): iterable
1616
{
17+
yield from $this->gatherAssertTypes(__DIR__ . '/data/messenger_handle_trait.php');
1718
yield from $this->gatherAssertTypes(__DIR__ . '/data/envelope_all.php');
1819
yield from $this->gatherAssertTypes(__DIR__ . '/data/header_bag_get.php');
1920
yield from $this->gatherAssertTypes(__DIR__ . '/data/response_header_bag_get_cookies.php');

‎tests/Type/Symfony/container.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,5 +354,23 @@
354354
<service id="parameterised_foo" class="%app.class%"></service>
355355
<service id="parameterised_bar" class="%app.class%\Bar"></service>
356356
<service id="synthetic" class="Synthetic" public="true" synthetic="true" />
357+
<service id="regular_query_handler" class="MessengerHandleTrait\RegularQueryHandler">
358+
<tag name="messenger.message_handler"/>
359+
</service>
360+
<service id="multi_query_handler" class="MessengerHandleTrait\MultiQueryHandler">
361+
<tag name="messenger.message_handler"/>
362+
</service>
363+
<service id="tagged_handler" class="MessengerHandleTrait\TaggedHandler">
364+
<tag name="messenger.message_handler" handles="MessengerHandleTrait\TaggedQuery" method="handle"/>
365+
</service>
366+
<service id="multi_handles_for_in_the_same_handler" class="MessengerHandleTrait\MultiHandlesForInTheSameHandler">
367+
<tag name="messenger.message_handler"/>
368+
</service>
369+
<service id="multi_handlers_for_the_same_message_handler_1" class="MessengerHandleTrait\MultiHandlersForTheSameMessageHandler1">
370+
<tag name="messenger.message_handler"/>
371+
</service>
372+
<service id="multi_handlers_for_the_same_message_handler_2" class="MessengerHandleTrait\MultiHandlersForTheSameMessageHandler2">
373+
<tag name="messenger.message_handler"/>
374+
</service>
357375
</services>
358376
</container>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace MessengerHandleTrait;
4+
5+
use Symfony\Component\Messenger\Handler\MessageSubscriberInterface;
6+
use Symfony\Component\Messenger\HandleTrait;
7+
use function PHPStan\Testing\assertType;
8+
9+
class RegularQuery {}
10+
class RegularQueryResult {}
11+
class RegularQueryHandler
12+
{
13+
public function __invoke(RegularQuery $query): RegularQueryResult
14+
{
15+
return new RegularQueryResult();
16+
}
17+
}
18+
19+
class BooleanQuery {}
20+
class StringQuery {}
21+
class IntQuery {}
22+
class FloatQuery {}
23+
class MultiQueryHandler implements MessageSubscriberInterface
24+
{
25+
public static function getHandledMessages(): iterable
26+
{
27+
yield BooleanQuery::class;
28+
yield IntQuery::class => ['method' => 'handleInt'];
29+
yield FloatQuery::class => ['method' => 'handleFloat'];
30+
yield StringQuery::class => ['method' => 'handleString'];
31+
}
32+
33+
public function __invoke(BooleanQuery $query): bool
34+
{
35+
return true;
36+
}
37+
38+
public function handleInt(IntQuery $query): int
39+
{
40+
return 0;
41+
}
42+
43+
public function handleFloat(FloatQuery $query): float
44+
{
45+
return 0.0;
46+
}
47+
48+
public function handleString(StringQuery $query): string
49+
{
50+
return 'string result';
51+
}
52+
}
53+
54+
class TaggedQuery {}
55+
class TaggedResult {}
56+
class TaggedHandler
57+
{
58+
public function handle(TaggedQuery $query): TaggedResult
59+
{
60+
return new TaggedResult();
61+
}
62+
}
63+
64+
class MultiHandlesForInTheSameHandlerQuery {}
65+
class MultiHandlesForInTheSameHandler implements MessageSubscriberInterface
66+
{
67+
public static function getHandledMessages(): iterable
68+
{
69+
yield MultiHandlesForInTheSameHandlerQuery::class;
70+
yield MultiHandlesForInTheSameHandlerQuery::class => ['priority' => '0'];
71+
}
72+
73+
public function __invoke(MultiHandlesForInTheSameHandlerQuery $query): bool
74+
{
75+
return true;
76+
}
77+
}
78+
79+
class MultiHandlersForTheSameMessageQuery {}
80+
class MultiHandlersForTheSameMessageHandler1
81+
{
82+
public function __invoke(MultiHandlersForTheSameMessageQuery $query): bool
83+
{
84+
return true;
85+
}
86+
}
87+
class MultiHandlersForTheSameMessageHandler2
88+
{
89+
public function __invoke(MultiHandlersForTheSameMessageQuery $query): bool
90+
{
91+
return false;
92+
}
93+
}
94+
95+
class HandleTraitClass {
96+
use HandleTrait;
97+
98+
public function __invoke()
99+
{
100+
assertType(RegularQueryResult::class, $this->handle(new RegularQuery()));
101+
102+
assertType('bool', $this->handle(new BooleanQuery()));
103+
assertType('int', $this->handle(new IntQuery()));
104+
assertType('float', $this->handle(new FloatQuery()));
105+
assertType('string', $this->handle(new StringQuery()));
106+
107+
assertType(TaggedResult::class, $this->handle(new TaggedQuery()));
108+
109+
// HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query
110+
assertType('mixed', $this->handle(new MultiHandlesForInTheSameHandlerQuery()));
111+
assertType('mixed', $this->handle(new MultiHandlersForTheSameMessageQuery()));
112+
}
113+
}

0 commit comments

Comments
 (0)
Please sign in to comment.