phpstan-symfony
phpstan-symfony copied to clipboard
Using messenger HandleTrait as QueryBus with appropriate result typing
Recently, my PR about support for Messenger HandleTrait return types was merged. I would like to use that work done to ability for QueryBus classes to "learn them" how to determine their results correctly, wherever they are called.
There's related PR to this missing functionality, which demonstrate what I want to achieve. The most reasonably (and the easiest way of doing it) from my understanding would be getting query-result mapping (which HandleTrait::handle method is aware now), and reusing it as the same input-output values for QueryBus::dispatch method (from the PR's example). Perhaps, using some annotations?
Maybe it is trivial to complete, but at the moment I do not know how to connect these dots 😅 I will try to push this topic forward, but any thoughts/ideas are welcome :)
@ondrejmirtes it's the following part of your request here 😉
anyone? any idea please? 😅
/cc @ondrejmirtes @shish @lookyman @VincentLanglet @ruudk @JanTvrdik @staabm
This is how I do it in our project, where we have our own CommandBus interface (that has a Symfony Messenger implementation).
You can use this as inspiration 😉
Our handlers implement a custom #[AsCommandHandler] that we can read in a Bundle. Something like this:
private function configureAttributes(ContainerBuilder $builder) : void
{
$builder->registerAttributeForAutoconfiguration(
AsCommandHandler::class,
function (ChildDefinition $definition, AsCommandHandler $attribute, ReflectionMethod $reflector) : void {
$methodParameters = $this->getMethodParameterTypes($reflector);
foreach ($methodParameters[0] as $commandClassName) {
$definition->addTag('command_handler', $tagAttributes); // This is the magic!
}
},
);
}
/**
* Returns a list of parameter types for the passed method. Some types can be union types,
* so each type is represented by a list.
*
* Example return values:
*
* [ [ UserCreatedEvent::class ] ]
* [ [ UserCreatedEvent::class, WhateverEvent::class, OtherEvent::class ] ]
* [ [ CreateUserCommand::class ] ]
* [ [ CreateUserCommand::class ], [ Actor::class ] ]
*
* Throws if any of the parameters does not have a type, or has a type that is neither simple nor union.
*
* @throws Exception
* @return list<list<class-string>>
*/
private function getMethodParameterTypes(ReflectionMethod $reflector) : array
{
$parameters = $reflector->getParameters();
$types = [];
foreach ($parameters as $parameter) {
if ($parameter->isOptional()) {
throw new Exception(sprintf(
'Method %s() must not have optional parameters',
Reflector::stringify($reflector),
));
}
$type = $parameter->getType();
if ($type === null) {
throw new Exception(sprintf(
'Parameter $%s of method %s() must have a type hint',
$parameters[0]->getName(),
Reflector::stringify($reflector),
));
}
if ( ! $type instanceof ReflectionNamedType && ! $type instanceof ReflectionUnionType) {
throw new Exception(sprintf(
'Parameter $%s of method %s() has invalid type hint',
$parameters[0]->getName(),
Reflector::stringify($reflector),
));
}
if ($type instanceof ReflectionUnionType) {
$types[] = ListHelper::map(fn($type) => $type->getName(), $type->getTypes());
} else {
$types[] = [$type->getName()];
}
}
return $types;
}
Then we have a CompilerPass that writes the mapping to a parameter: command_handlers.
final readonly class CollectCommandHandlersPass implements CompilerPassInterface
{
#[Override]
public function process(ContainerBuilder $container) : void
{
$commandToHandlerMapping = [];
foreach ($container->findTaggedServiceIds('command_handlers') as $id => $tags) {
foreach ($tags as $tag) {
$commandToHandlerMapping[$tag['handles']] = [$id, $tag['method']];
}
}
$container->setParameter('command_handlers', $commandToHandlerMapping);
}
}
We have the following files for PHPStan.
This reads the mapping from the container.
<?php // src-dev/PHPStan/command-bus-mapping.php
declare(strict_types=1);
use TicketSwap\Kernel;
use TicketSwap\Shared\Infrastructure\Config\EnvironmentName;
require_once __DIR__ . '/../../autoload.php';
$env = EnvironmentName::Dev;
if (isset($_SERVER['APP_ENV'])) {
$env = EnvironmentName::create($_SERVER['APP_ENV']);
}
$kernel = new Kernel($env, true);
$kernel->boot();
return $kernel->getContainer()->getParameter('command_handlers');
<?php // src-dev/PHPStan/Extension/CommandBusMapping.php
declare(strict_types=1);
namespace Dev\PHPStan\Extension;
final readonly class CommandBusMapping
{
/**
* @return null|array{string, string}
*/
public function get(string $commandName) : ?array
{
static $mapping = include __DIR__ . '/../command-bus-mapping.php';
return $mapping[$commandName] ?? null;
}
}
This is a return type extension that is able to figure out what $bus->handle(new SomeCommand)) returns.
<?php // src-dev/PHPStan/Extension/CommandBusReturnTypeExtension.php
declare(strict_types=1);
namespace Dev\PHPStan\Extension;
use Override;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
use TicketSwap\Shared\Infrastructure\Messaging\MessageBus\CommandBus;
final readonly class CommandBusReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function __construct(
private ReflectionProvider $reflectionProvider,
private CommandBusMapping $commandBusMapping,
) {}
#[Override]
public function getClass() : string
{
return CommandBus::class;
}
#[Override]
public function isMethodSupported(MethodReflection $methodReflection) : bool
{
return $methodReflection->getName() === 'handle';
}
#[Override]
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope,
) : ?Type {
if ( ! $methodCall->args[0] instanceof Arg) {
return null;
}
$classNames = $scope->getType($methodCall->args[0]->value)->getObjectClassNames();
if (count($classNames) !== 1) {
return null;
}
$handlerAndMethod = $this->commandBusMapping->get($classNames[0]);
if ($handlerAndMethod === null) {
return null;
}
[$handler, $method] = $handlerAndMethod;
if ( ! $this->reflectionProvider->hasClass($handler)) {
return null;
}
$handlerReflection = $this->reflectionProvider->getClass($handler);
if ( ! $handlerReflection->hasMethod($method)) {
return null;
}
$methodReflection = $handlerReflection->getMethod($method, $scope);
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants(),
)->getReturnType();
}
}
<?php // src-dev/PHPStan/Extension/CommandBusThrowTypeExtension.php
declare(strict_types=1);
namespace Dev\PHPStan\Extension;
use Override;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\DynamicMethodThrowTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use TicketSwap\Shared\Infrastructure\Messaging\MessageBus\Attribute\IgnoreException;
use TicketSwap\Shared\Infrastructure\Messaging\MessageBus\CommandBus;
final readonly class CommandBusThrowTypeExtension implements DynamicMethodThrowTypeExtension
{
public function __construct(
private ReflectionProvider $reflectionProvider,
private CommandBusMapping $commandBusMapping,
) {}
#[Override]
public function isMethodSupported(MethodReflection $methodReflection) : bool
{
return $methodReflection->getDeclaringClass()->is(CommandBus::class) && $methodReflection->getName() === 'handle';
}
#[Override]
public function getThrowTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope,
) : ?Type {
$defaultThrowType = $methodReflection->getThrowType();
if ( ! $methodCall->args[0] instanceof Arg) {
return $defaultThrowType;
}
$commandNames = $scope->getType($methodCall->args[0]->value)->getObjectClassNames();
if ($commandNames === []) {
return $defaultThrowType;
}
$throwTypes = [];
if ($defaultThrowType !== null) {
$throwTypes[] = $defaultThrowType;
}
foreach ($commandNames as $commandName) {
$throwType = $this->getThrowTypeForCommand($commandName, $scope);
if ($throwType === null) {
continue;
}
$throwTypes[] = $throwType;
}
return TypeCombinator::union(...$throwTypes);
}
private function getThrowTypeForCommand(string $commandName, Scope $scope) : ?Type
{
$handlerAndMethod = $this->commandBusMapping->get($commandName);
if ($handlerAndMethod === null) {
return null;
}
[$handler, $method] = $handlerAndMethod;
if ( ! $this->reflectionProvider->hasClass($handler)) {
return null;
}
$handlerReflection = $this->reflectionProvider->getClass($handler);
if ( ! $handlerReflection->hasMethod($method)) {
return null;
}
$methodReflection = $handlerReflection->getMethod($method, $scope);
$throwType = $methodReflection->getThrowType();
if ($throwType === null) {
return null;
}
$ignoreExceptions = $handlerReflection
->getNativeReflection()
->getMethod($method)
->getAttributes(IgnoreException::class);
foreach ($ignoreExceptions as $ignoreException) {
if ( ! isset($ignoreException->getArguments()[0])) {
continue;
}
if ( ! is_string($ignoreException->getArguments()[0])) {
continue;
}
$throwType = TypeCombinator::remove($throwType, new ObjectType($ignoreException->getArguments()[0]));
}
return $throwType;
}
}
Thank you @ruudk for your detailed response and sorry for my late reply.
In fact, your solution is pretty similar to what I already done here.
It also creates kind of (command/query => result) map which PHPStan extension uses to determine the result statically. The differences in both implementations are that you have some additional custom & static part of code coupled with SF and project's CommandBus class dependency in PHPStan extension. My solution uses container which is already configured in phpstan-symfony settings (independently from project code) and do similar work, however it works currently only on level for class which uses messenger HandleTrait (only internally). That's the case of that issue.
Ideally, I'd like to reuse somehow already created map (internally in that plugin) to "learn" any bus classes (which return some single result using HandleTrait). I'd love to this in most simple way without need to adding any custom code to project codebase (excluding some annotation which PHPStan could understand) or add some code into this plugin which would help in "learning" that (eg. some extension for buses as you did, however more dynamically to not depend on any specific or do this in configurable way) 😉