Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using messenger HandleTrait as QueryBus with appropriate result typing #424

Open
bnowak opened this issue Jan 17, 2025 · 4 comments
Open

Comments

@bnowak
Copy link
Contributor

bnowak commented Jan 17, 2025

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

@bnowak
Copy link
Contributor Author

bnowak commented Jan 17, 2025

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

@bnowak
Copy link
Contributor Author

bnowak commented Feb 17, 2025

anyone? any idea please? 😅

/cc @ondrejmirtes @shish @lookyman @VincentLanglet @ruudk @JanTvrdik @staabm

@ruudk
Copy link
Contributor

ruudk commented Feb 17, 2025

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
Copy link
Contributor Author

bnowak commented Mar 14, 2025

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
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants