Skip to content

Using messenger HandleTrait as QueryBus with appropriate result typing #424

Open
@bnowak

Description

@bnowak

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 :)

Activity

bnowak

bnowak commented on Jan 17, 2025

@bnowak
ContributorAuthor

@ondrejmirtes it's the following part of your request here 😉

bnowak

bnowak commented on Feb 17, 2025

@bnowak
ContributorAuthor
ruudk

ruudk commented on Feb 17, 2025

@ruudk
Contributor

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;
    }
}
bnowak

bnowak commented on Mar 14, 2025

@bnowak
ContributorAuthor

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) 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      Using messenger HandleTrait as QueryBus with appropriate result typing · Issue #424 · phpstan/phpstan-symfony