diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 84403c7fd..9f68daf9e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -21,7 +21,6 @@ jobs: - "lowest" - "highest" php-version: - - "8.1" - "8.2" - "8.3" operating-system: diff --git a/baseline.xml b/baseline.xml index dac0b4894..c34039bb6 100644 --- a/baseline.xml +++ b/baseline.xml @@ -16,6 +16,25 @@ frozenDateTime->modify(sprintf('+%s seconds', $seconds))]]> + + + parameterResolver->resolve($reflectionMethod, $command)]]> + + + + + parameterResolver->resolve($reflectionMethod, $command)]]> + + + + + method}(...)]]> + + + method]]> + method}(...)]]> + + @@ -57,9 +76,6 @@ - - - aggregateMetadata[$aggregate]]]> @@ -219,6 +235,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + command]]> + + @@ -229,11 +286,6 @@ - - - - - diff --git a/composer.json b/composer.json index 19a5fb12e..586bf010d 100644 --- a/composer.json +++ b/composer.json @@ -19,22 +19,24 @@ } ], "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.2.0 || ~8.3.0", "doctrine/dbal": "^4.0.0", "doctrine/migrations": "^3.3.2", "patchlevel/hydrator": "^1.5.0", "patchlevel/worker": "^1.2.0", "psr/cache": "^2.0.0|^3.0.0", "psr/clock": "^1.0", + "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", "psr/log": "^2.0.0|^3.0.0", "psr/simple-cache": "^2.0.0|^3.0.0", "ramsey/uuid": "^4.7", "symfony/console": "^5.4.32|^6.4.1|^7.0.1", - "symfony/finder": "^5.4.27|^6.4.0|^7.0.0" + "symfony/finder": "^5.4.27|^6.4.0|^7.0.0", + "symfony/type-info": "^7.2" }, "require-dev": { - "ext-pdo_sqlite": "~8.1.0 || ~8.2.0 || ~8.3.0", + "ext-pdo_sqlite": "~8.2.0 || ~8.3.0", "cspray/phinal": "^2.0.0", "doctrine/orm": "^2.18.0|^3.0.0", "infection/infection": "^0.27.10", diff --git a/composer.lock b/composer.lock index 83992c3b8..ef7f57827 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c2224a1076fbef35e7ec7873bc9e1aa9", + "content-hash": "d7879db342a8541f59507e4410ec5826", "packages": [ { "name": "brick/math", @@ -415,21 +415,22 @@ }, { "name": "patchlevel/hydrator", - "version": "1.5.1", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "b2cc966bca642569f0dc6cd74cc77c34de52b7a9" + "reference": "484ed357e0647c01b4f6d4d4dc0d9732daa11dac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/b2cc966bca642569f0dc6cd74cc77c34de52b7a9", - "reference": "b2cc966bca642569f0dc6cd74cc77c34de52b7a9", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/484ed357e0647c01b4f6d4d4dc0d9732daa11dac", + "reference": "484ed357e0647c01b4f6d4d4dc0d9732daa11dac", "shasum": "" }, "require": { "ext-openssl": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.2.0 || ~8.3.0", + "symfony/type-info": "^7.2.2" }, "require-dev": { "cspray/phinal": "^2.0.0", @@ -437,7 +438,7 @@ "patchlevel/coding-standard": "^1.3.0", "phpbench/phpbench": "^1.2.15", "phpspec/prophecy-phpunit": "^2.1.0", - "phpstan/phpstan": "^1.10.49", + "phpstan/phpstan": "^2.1.0", "phpunit/phpunit": "^10.5.2", "psalm/plugin-phpunit": "^0.19.0", "psr/cache": "^2.0.0|^3.0.0", @@ -474,9 +475,9 @@ ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/1.5.1" + "source": "https://github.com/patchlevel/hydrator/tree/1.6.0" }, - "time": "2024-10-15T11:28:23+00:00" + "time": "2025-01-02T11:56:20+00:00" }, { "name": "patchlevel/worker", @@ -1953,6 +1954,81 @@ ], "time": "2024-11-13T13:31:26+00:00" }, + { + "name": "symfony/type-info", + "version": "v7.2.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3b5a17470fff0034f25fd4287cbdaa0010d2f749", + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.0|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.2.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-20T13:38:37+00:00" + }, { "name": "symfony/var-exporter", "version": "v7.2.0", @@ -8282,14 +8358,14 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.2.0 || ~8.3.0" }, "platform-dev": { - "ext-pdo_sqlite": "~8.1.0 || ~8.2.0 || ~8.3.0" + "ext-pdo_sqlite": "~8.2.0 || ~8.3.0" }, "plugin-api-version": "2.6.0" } diff --git a/deptrac.yaml b/deptrac.yaml index b793814f6..941f4891d 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -17,6 +17,10 @@ deptrac: collectors: - type: directory value: src/Clock/.* + - name: CommandBus + collectors: + - type: directory + value: src/CommandBus/.* - name: Console collectors: - type: directory @@ -103,6 +107,11 @@ deptrac: - MetadataAggregate Attribute: Clock: + CommandBus: + - Aggregate + - Attribute + - MetadataAggregate + - Repository Console: - Aggregate - Message diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2ee820843..f3e85c770 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -97,6 +97,7 @@ nav: - Message: message.md - Store: store.md - Subscription: subscription.md + - Command Bus: command_bus.md - Event Bus: event_bus.md - Advanced: - Aggregate ID: aggregate_id.md diff --git a/docs/pages/command_bus.md b/docs/pages/command_bus.md new file mode 100644 index 000000000..1c0ba692a --- /dev/null +++ b/docs/pages/command_bus.md @@ -0,0 +1,323 @@ +# Command Bus + +The Command Bus is an optional component in the Event Sourcing library that coordinates the execution of commands. +It allows commands to be forwarded to the appropriate aggregates and their handlers to be invoked. +This promotes a clear separation of responsibilities and simplifies the management of business logic. + +## Command + +First of all, you need to create a command class. +A command is a simple data transfer object that represents an intention to perform an action. + +```php +final class CreateProfile +{ + public function __construct( + public readonly ProfileId $id, + public readonly string $name, + ) { + } +} +``` +## Handler + +Then you need to create a handler class. +A handler is a class that contains the business logic for a command. +It will be invoked when a command is dispatched. +You need to mark the method that handles the command with the `#[Handle]` attribute. + +```php +use Patchlevel\EventSourcing\Attribute\Handle; + +final class CreateProfileHandler +{ + #[Handle] + public function __invoke(CreateProfile $command): void + { + // handle command + } +} +``` +!!! note + + To use Service Handler you need to register the handler in the `ServiceHandlerProvider`. + +!!! tip + + A class can have multiple handle methods. + +### Aggregate Handler + +Another way to handle commands is to use the aggregates themselves. +To do this, you need to mark the method that handles the command with the `#[Handle]` attribute. + +!!! note + + The aggregates themselves are of course not a service. + The AggregateHandlerProvider uses the aggregates to create the handlers for you. + You can find out more about this in the [providers](./command_bus.md#provider) section. + +#### Create Aggregate + +If you want to create a new aggregate, you need to create a static method that returns a new instance of the aggregate. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\Attribute\Id; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Id] + private ProfileId $id; + private string $name; + + #[Handle] + public static function create(CreateProfile $command): self + { + $self = new self(); + $self->recordThat(new ProfileCreated($command->id, $command->name)); + + return $self; + } + + // ... apply methods +} +``` +!!! tip + + You can find more information about aggregates [here](aggregate.md). + +#### Update Aggregate + +If you want to update an existing aggregate, +first you need to mark the `aggregate id` with the `#[Id]` attribute in the command class. +Otherwise, the handler does not know which aggregates should be loaded. + +```php +use Patchlevel\EventSourcing\Attribute\Id; + +final class ChangeProfileName +{ + public function __construct( + #[Id] + public readonly ProfileId $id, + public readonly string $name, + ) { + } +} +``` +Then you need to create a method that changes the aggregate state. +Here too, you need to mark the method with the `#[Handle]` attribute. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\Attribute\Id; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Id] + private ProfileId $id; + private string $name; + + #[Handle] + public function changeName(ChangeProfileName $command): void + { + if (!$nameValidator($command->name)) { + throw new InvalidArgument(); + } + + $this->recordThat(new NameChanged($command->name)); + } + + // ... apply methods +} +``` +#### Inject Service + +You can inject services into aggregate handler methods. +Starting with the second parameter, it automatically tries to inject the service using a service locator. +Standard, it uses the fully qualified class name from the parameter type hint to find the service. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Handle; +use Psr\Clock\ClockInterface; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Handle] + public static function create( + CreateProfile $command, + ClockInterface $clock, + ): self { + $self = new self(); + + $self->recordThat(new ProfileCreated( + $command->id, + $command->name, + $clock->now(), + )); + + return $self; + } + + // ... apply methods +} +``` +!!! note + + The service must be registered in the service locator. + +!!! tip + + You can inject multiple services into the handler method. + +Or you can inject the service manually using the `#[Inject]` attribute. +There you can specify the service name that should be injected. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\Attribute\Inject; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Handle] + public static function create( + CreateProfile $command, + #[Inject('name_validator')] + NameValidator $nameValidator, + ): self { + $self = new self(); + + if (!$nameValidator($command->name)) { + throw new InvalidArgument(); + } + + $self->recordThat(new ProfileCreated($command->id, $command->name)); + + return $self; + } + + // ... apply methods +} +``` +!!! note + + Injection in handler methods is only possible with the `AggregateHandlerProvider`. + +## Setup + +We provide a `SyncCommandBus` that you can use to dispatch commands. +You need to pass a `HandlerProvider` to the constructor. + +```php +use Patchlevel\EventSourcing\CommandBus\HandlerProvider; +use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; + +/** @var HandlerProvider $handlerProvider */ +$commandBus = new SyncCommandBus($handlerProvider); + +$commandBus->dispatch(new CreateProfile($profileId, 'name')); +$commandBus->dispatch(new ChangeProfileName($profileId, 'new name')); +``` +!!! note + + The `SyncCommandBus` is a synchronous command bus. + But it ensures that a command has been completely handled before the next handler is executed. + +## Provider + +There are different types of providers that you can use to register handlers. + +### Service Handler Provider + +The classically way to handle commands is to use services. +The `ServiceHandlerProvider` is used to handle commands by invoking methods on services. + +```php +use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; + +$provider = new ServiceHandlerProvider([ + new CreateProfileHandler(), + new ChangeProfileNameHandler( + new NameValidator(), + ), +]); +``` +### Aggregate Handler Provider + +The `AggregateHandlerProvider` is used to handle commands by invoking methods on aggregates. +The special thing about it is that the aggregates themselves are not services, +but the handler provider automatically creates suitable handler services for the aggregates. + +```php +use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Repository\RepositoryManager; + +/** + * @var AggregateRootRegistry $aggregateRootRegistry + * @var RepositoryManager $repositoryManager + */ +$provider = new AggregateHandlerProvider( + $aggregateRootRegistry, + $repositoryManager, +); +``` +#### Service Locator + +If you want service injection in aggregate handler methods, +you need to pass a service locator to the `AggregateHandlerProvider`. +You can use any psr-11 compatible container, or you can use our implementation `ServiceLocator`. + +```php +use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider; +use Patchlevel\EventSourcing\CommandBus\ServiceLocator; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Repository\RepositoryManager; + +/** + * @var AggregateRootRegistry $aggregateRootRegistry + * @var RepositoryManager $repositoryManager + */ +$provider = new AggregateHandlerProvider( + $aggregateRootRegistry, + $repositoryManager, + new ServiceLocator([ + 'name_validator' => new NameValidator(), + ]), // or other psr-11 compatible container +); +``` +!!! tip + + You can find suitable implementations of psr-11 containers on [packagist](https://packagist.org/search/?tags=PSR-11). + +### Chain Handler Provider + +The `ChainHandlerProvider` allows you to combine multiple handler providers. + +```php +use Patchlevel\EventSourcing\CommandBus\ChainHandlerProvider; + +$provider = new ChainHandlerProvider([ + $serviceHandlerProvider, + $aggregateHandlerProvider, +]); +``` +## Learn more + +* [How to use aggregates](aggregate.md) +* [How to use events](events.md) +* [How to use clock](clock.md) +* [How to use aggregate id](aggregate_id.md) diff --git a/src/Attribute/Handle.php b/src/Attribute/Handle.php new file mode 100644 index 000000000..65a5bdfa3 --- /dev/null +++ b/src/Attribute/Handle.php @@ -0,0 +1,17 @@ +> */ + private array $handlers = []; + + public function __construct( + private readonly AggregateRootRegistry $aggregateRootRegistry, + private readonly RepositoryManager $repositoryManager, + private readonly ContainerInterface|null $container = null, + ) { + } + + /** + * @param class-string $commandClass + * + * @return iterable + */ + public function handlerForCommand(string $commandClass): iterable + { + if (!$this->initialized) { + $this->initialize(); + } + + return $this->handlers[$commandClass] ?? []; + } + + private function initialize(): void + { + foreach ($this->aggregateRootRegistry->aggregateClasses() as $aggregateClass) { + foreach (HandlerFinder::findInClass($aggregateClass) as $handler) { + if ($handler->static) { + $this->handlers[$handler->commandClass][] = new HandlerDescriptor( + new CreateAggregateHandler( + $this->repositoryManager, + $aggregateClass, + $handler->method, + new DefaultParameterResolver($this->container), + ), + ); + + continue; + } + + $this->handlers[$handler->commandClass][] = new HandlerDescriptor( + new UpdateAggregateHandler( + $this->repositoryManager, + $aggregateClass, + $handler->method, + new DefaultParameterResolver($this->container), + ), + ); + } + } + + $this->initialized = true; + } +} diff --git a/src/CommandBus/ChainHandlerProvider.php b/src/CommandBus/ChainHandlerProvider.php new file mode 100644 index 000000000..8ea809b33 --- /dev/null +++ b/src/CommandBus/ChainHandlerProvider.php @@ -0,0 +1,33 @@ + $providers */ + public function __construct( + private readonly iterable $providers, + ) { + } + + /** + * @param class-string $commandClass + * + * @return iterable + */ + public function handlerForCommand(string $commandClass): iterable + { + $handlers = []; + + foreach ($this->providers as $provider) { + $handlers = [ + ...$handlers, + ...$provider->handlerForCommand($commandClass), + ]; + } + + return $handlers; + } +} diff --git a/src/CommandBus/CommandBus.php b/src/CommandBus/CommandBus.php new file mode 100644 index 000000000..f47ee9c28 --- /dev/null +++ b/src/CommandBus/CommandBus.php @@ -0,0 +1,11 @@ + $aggregateClass */ + public function __construct( + private readonly RepositoryManager $repositoryManager, + private readonly string $aggregateClass, + private readonly string $methodName, + private readonly ParameterResolver $parameterResolver, + ) { + } + + public function __invoke(object $command): void + { + $repository = $this->repositoryManager->get($this->aggregateClass); + + $reflection = new ReflectionClass($this->aggregateClass); + $reflectionMethod = $reflection->getMethod($this->methodName); + + $aggregate = $reflectionMethod->invokeArgs( + null, + [...$this->parameterResolver->resolve($reflectionMethod, $command)], + ); + + if (!$aggregate instanceof AggregateRoot) { + throw new InvalidArgumentException('create method must return an instance of AggregateRoot'); + } + + $repository->save($aggregate); + } +} diff --git a/src/CommandBus/Handler/DefaultParameterResolver.php b/src/CommandBus/Handler/DefaultParameterResolver.php new file mode 100644 index 000000000..d9e8bd8bc --- /dev/null +++ b/src/CommandBus/Handler/DefaultParameterResolver.php @@ -0,0 +1,71 @@ + */ + public function resolve(ReflectionMethod $method, object $command): iterable + { + foreach ($method->getParameters() as $index => $parameter) { + if ($index === 0) { + yield $command; // first parameter is always the command + + continue; + } + + if (!$this->container) { + throw ServiceNotResolvable::missingContainer(); + } + + try { + yield $this->container->get(self::serviceName($method, $parameter)); + } catch (ContainerExceptionInterface $exception) { + throw ServiceNotResolvable::missingService( + $method->getDeclaringClass()->getName(), + $method->getName(), + $parameter->getName(), + $exception, + ); + } + } + } + + private function serviceName(ReflectionMethod $method, ReflectionParameter $parameter): string + { + $attributes = $parameter->getAttributes(Inject::class); + + if ($attributes !== []) { + return $attributes[0]->newInstance()->service; + } + + $reflectionType = $parameter->getType(); + + if ($reflectionType === null) { + throw ServiceNotResolvable::missingType($method->getDeclaringClass()->getName(), $parameter->getName()); + } + + $type = TypeResolver::create()->resolve($reflectionType); + + if (!$type instanceof ObjectType) { + throw ServiceNotResolvable::typeNotObject($method->getDeclaringClass()->getName(), $parameter->getName()); + } + + return $type->getClassName(); + } +} diff --git a/src/CommandBus/Handler/ParameterResolver.php b/src/CommandBus/Handler/ParameterResolver.php new file mode 100644 index 000000000..53145a801 --- /dev/null +++ b/src/CommandBus/Handler/ParameterResolver.php @@ -0,0 +1,13 @@ + */ + public function resolve(ReflectionMethod $method, object $command): iterable; +} diff --git a/src/CommandBus/Handler/ServiceNotResolvable.php b/src/CommandBus/Handler/ServiceNotResolvable.php new file mode 100644 index 000000000..79913d386 --- /dev/null +++ b/src/CommandBus/Handler/ServiceNotResolvable.php @@ -0,0 +1,45 @@ +getMessage(), + ), + 0, + $exception, + ); + } +} diff --git a/src/CommandBus/Handler/UpdateAggregateHandler.php b/src/CommandBus/Handler/UpdateAggregateHandler.php new file mode 100644 index 000000000..b3adfb8c5 --- /dev/null +++ b/src/CommandBus/Handler/UpdateAggregateHandler.php @@ -0,0 +1,65 @@ + $aggregateClass */ + public function __construct( + private readonly RepositoryManager $repositoryManager, + private readonly string $aggregateClass, + private readonly string $methodName, + private readonly ParameterResolver $parameterResolver, + ) { + } + + public function __invoke(object $command): void + { + $aggregateRootId = $this->aggregateRootId($command); + $repository = $this->repositoryManager->get($this->aggregateClass); + + $aggregate = $repository->load($aggregateRootId); + + $reflection = new ReflectionClass($this->aggregateClass); + $reflectionMethod = $reflection->getMethod($this->methodName); + + $reflectionMethod->invokeArgs( + $aggregate, + [...$this->parameterResolver->resolve($reflectionMethod, $command)], + ); + + $repository->save($aggregate); + } + + private function aggregateRootId(object $command): AggregateRootId + { + $reflectionClass = new ReflectionClass($command); + + foreach ($reflectionClass->getProperties() as $property) { + $attributes = $property->getAttributes(Id::class); + + if ($attributes === []) { + continue; + } + + $value = $property->getValue($command); + + if (!$value instanceof AggregateRootId) { + throw new InvalidArgumentException('Id property must be an instance of AggregateRootId'); + } + + return $value; + } + + throw new AggregateIdNotFound($command::class); + } +} diff --git a/src/CommandBus/HandlerDescriptor.php b/src/CommandBus/HandlerDescriptor.php new file mode 100644 index 000000000..99a68a3e9 --- /dev/null +++ b/src/CommandBus/HandlerDescriptor.php @@ -0,0 +1,49 @@ +callable = $callable(...); + $this->name = self::closureName($this->callable); + } + + public function name(): string + { + return $this->name; + } + + public function callable(): callable + { + return $this->callable; + } + + private static function closureName(Closure $closure): string + { + $reflectionFunction = new ReflectionFunction($closure); + + if ($reflectionFunction->isAnonymous()) { + return 'Closure'; + } + + $closureThis = $reflectionFunction->getClosureThis(); + + if (!$closureThis) { + $class = $reflectionFunction->getClosureCalledClass(); + + return ($class ? $class->name . '::' : '') . $reflectionFunction->name; + } + + return $closureThis::class . '::' . $reflectionFunction->name; + } +} diff --git a/src/CommandBus/HandlerFinder.php b/src/CommandBus/HandlerFinder.php new file mode 100644 index 000000000..f00af8609 --- /dev/null +++ b/src/CommandBus/HandlerFinder.php @@ -0,0 +1,80 @@ + + */ + public static function findInClass(string $classString): iterable + { + $typeResolver = TypeResolver::create(); + $reflectionClass = new ReflectionClass($classString); + + foreach ($reflectionClass->getMethods() as $reflectionMethod) { + $handleAttributes = $reflectionMethod->getAttributes(Handle::class); + + if ($handleAttributes === []) { + continue; + } + + $handle = $handleAttributes[0]->newInstance(); + + if ($handle->commandClass !== null) { + yield new HandlerReference( + $handle->commandClass, + $reflectionMethod->getName(), + $reflectionMethod->isStatic(), + ); + + continue; + } + + $parameters = $reflectionMethod->getParameters(); + + if ($parameters === []) { + throw InvalidHandleMethod::noParameters( + $reflectionMethod->getDeclaringClass()->getName(), + $reflectionMethod->getName(), + ); + } + + $reflectionType = $parameters[0]->getType(); + + if ($reflectionType === null) { + throw InvalidHandleMethod::incompatibleType( + $reflectionMethod->getDeclaringClass()->getName(), + $reflectionMethod->getName(), + ); + } + + $type = $typeResolver->resolve($reflectionType); + + if (!$type instanceof ObjectType) { + throw InvalidHandleMethod::incompatibleType( + $reflectionMethod->getDeclaringClass()->getName(), + $reflectionMethod->getName(), + ); + } + + /** @var class-string $commandClass */ + $commandClass = $type->getClassName(); + + yield new HandlerReference( + $commandClass, + $reflectionMethod->getName(), + $reflectionMethod->isStatic(), + ); + } + } +} diff --git a/src/CommandBus/HandlerNotFound.php b/src/CommandBus/HandlerNotFound.php new file mode 100644 index 000000000..476f4a17b --- /dev/null +++ b/src/CommandBus/HandlerNotFound.php @@ -0,0 +1,18 @@ + + */ + public function handlerForCommand(string $commandClass): iterable; +} diff --git a/src/CommandBus/HandlerReference.php b/src/CommandBus/HandlerReference.php new file mode 100644 index 000000000..3cb97550f --- /dev/null +++ b/src/CommandBus/HandlerReference.php @@ -0,0 +1,16 @@ +> */ + private array $handlers = []; + + /** @param iterable $services */ + public function __construct( + private readonly iterable $services, + ) { + } + + /** + * @param class-string $commandClass + * + * @return iterable + */ + public function handlerForCommand(string $commandClass): iterable + { + if (!$this->initialized) { + $this->initialize(); + } + + return $this->handlers[$commandClass] ?? []; + } + + private function initialize(): void + { + foreach ($this->services as $service) { + foreach (HandlerFinder::findInClass($service::class) as $handler) { + if ($handler->static) { + $this->handlers[$handler->commandClass][] = new HandlerDescriptor( + $service::{$handler->method}(...), + ); + + continue; + } + + $this->handlers[$handler->commandClass][] = new HandlerDescriptor( + $service->{$handler->method}(...), + ); + } + } + + $this->initialized = true; + } +} diff --git a/src/CommandBus/ServiceLocator.php b/src/CommandBus/ServiceLocator.php new file mode 100644 index 000000000..7881e0070 --- /dev/null +++ b/src/CommandBus/ServiceLocator.php @@ -0,0 +1,32 @@ + $services */ + public function __construct( + private readonly array $services = [], + ) { + } + + public function get(string $id): mixed + { + if (!array_key_exists($id, $this->services)) { + throw new ServiceNotFound($id); + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} diff --git a/src/CommandBus/ServiceNotFound.php b/src/CommandBus/ServiceNotFound.php new file mode 100644 index 000000000..ba021f622 --- /dev/null +++ b/src/CommandBus/ServiceNotFound.php @@ -0,0 +1,18 @@ + */ + private array $queue; + private bool $processing; + + /** @param iterable|HandlerProvider $handlerProviders */ + public function __construct( + iterable|HandlerProvider $handlerProviders, + private readonly LoggerInterface|null $logger = null, + ) { + if (!$handlerProviders instanceof HandlerProvider) { + $this->handlerProvider = new ChainHandlerProvider($handlerProviders); + } else { + $this->handlerProvider = $handlerProviders; + } + + $this->queue = []; + $this->processing = false; + } + + /** @throws HandlerNotFound */ + public function dispatch(object $command): void + { + $this->logger?->debug(sprintf( + 'CommandBus: Add message "%s" to queue.', + $command::class, + )); + + $this->queue[] = $command; + + if ($this->processing) { + $this->logger?->debug('CommandBus: Is already processing, dont start new processing.'); + + return; + } + + try { + $this->processing = true; + + $this->logger?->debug('CommandBus: Start processing queue.'); + + while ($command = array_shift($this->queue)) { + $handlers = $this->handlerProvider->handlerForCommand($command::class); + + if (!is_array($handlers)) { + $handlers = iterator_to_array($handlers); + } + + $count = count($handlers); + + if ($count === 0) { + throw new HandlerNotFound($command::class); + } + + if ($count > 1) { + throw new MultipleHandlersFound($command::class); + } + + ($handlers[0]->callable())($command); + } + } finally { + $this->processing = false; + + $this->logger?->debug('CommandBus: Finished processing queue.'); + } + } + + public static function createForAggregateHandlers( + AggregateRootRegistry $aggregateRootRegistry, + RepositoryManager $repositoryManager, + ContainerInterface|null $container = null, + LoggerInterface|null $logger = null, + ): self { + return new self( + new AggregateHandlerProvider( + $aggregateRootRegistry, + $repositoryManager, + $container, + ), + $logger, + ); + } +} diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index 81e3a5e09..374ce601a 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -6,6 +6,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Clock\SystemClock; +use Patchlevel\EventSourcing\CommandBus\ServiceLocator; +use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Pipe; use Patchlevel\EventSourcing\Message\Reducer; @@ -26,12 +29,15 @@ use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\ChangeProfileName; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\CreateProfile; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\MessageDecorator\FooMessageDecorator; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Processor\SendEmailProcessor; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Projection\ProfileProjector; use PHPUnit\Framework\TestCase; +use Psr\Clock\ClockInterface; /** @coversNothing */ final class BasicIntegrationTest extends TestCase @@ -239,4 +245,82 @@ public function testTempProjection(): void self::assertSame(['name' => 'John99'], $state); } + + public function testCommandBus(): void + { + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/Header', + ]), + ); + + $aggregateRootRegistry = new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), + $store, + null, + new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + new FooMessageDecorator(), + ); + + $profileProjection = new ProfileProjector($this->connection); + + $engine = new DefaultSubscriptionEngine( + $store, + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([ + $profileProjection, + new SendEmailProcessor(), + ]), + ); + + $manager = new RunSubscriptionEngineRepositoryManager( + $manager, + $engine, + ); + + $commandBus = SyncCommandBus::createForAggregateHandlers( + $aggregateRootRegistry, + $manager, + new ServiceLocator([ + ClockInterface::class => new SystemClock(), + 'env' => 'test', + ]), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + $engine->setup(skipBooting: true); + + $profileId = ProfileId::generate(); + + $commandBus->dispatch(new CreateProfile($profileId, 'John')); + $commandBus->dispatch(new ChangeProfileName($profileId, 'John Doe')); + + $result = $this->connection->fetchAssociative( + 'SELECT * FROM projection_profile WHERE id = ?', + [$profileId->toString()], + ); + + self::assertIsArray($result); + self::assertArrayHasKey('id', $result); + self::assertSame($profileId->toString(), $result['id']); + self::assertSame('John Doe', $result['name']); + + $repository = $manager->get(ProfileWithCommands::class); + $profile = $repository->load($profileId); + + self::assertInstanceOf(ProfileWithCommands::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(2, $profile->playhead()); + self::assertSame('John Doe', $profile->name()); + self::assertSame(1, SendEmailMock::count()); + } } diff --git a/tests/Integration/BasicImplementation/Command/ChangeProfileName.php b/tests/Integration/BasicImplementation/Command/ChangeProfileName.php new file mode 100644 index 000000000..7061192a9 --- /dev/null +++ b/tests/Integration/BasicImplementation/Command/ChangeProfileName.php @@ -0,0 +1,18 @@ +recordThat(new ProfileCreated($command->id, $command->name)); + + return $self; + } + + #[Handle] + public function changeName( + ChangeProfileName $command, + ClockInterface $clock, + #[Inject('env')] + string $env, + ): void { + $this->recordThat(new NameChanged($command->name)); + } + + #[Apply] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId; + $this->name = $event->name; + } + + #[Apply] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name; + } + + public function name(): string + { + return $this->name; + } +} diff --git a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php index 87f80777a..1e33973fc 100644 --- a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php +++ b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php @@ -10,7 +10,9 @@ use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Teardown; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\ProfileId; #[Projector('profile-1')] final class ProfileProjector @@ -48,4 +50,16 @@ public function handleProfileCreated(ProfileCreated $profileCreated): void ], ); } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $nameChanged, ProfileId $profileId): void + { + $this->connection->executeStatement( + 'UPDATE projection_profile SET name = :name WHERE id = :id;', + [ + 'id' => $profileId->toString(), + 'name' => $nameChanged->name, + ], + ); + } } diff --git a/tests/Unit/CommandBus/AggregateHandlerProviderTest.php b/tests/Unit/CommandBus/AggregateHandlerProviderTest.php new file mode 100644 index 000000000..8be2db200 --- /dev/null +++ b/tests/Unit/CommandBus/AggregateHandlerProviderTest.php @@ -0,0 +1,81 @@ +prophesize(RepositoryManager::class); + + $provider = new AggregateHandlerProvider( + new AggregateRootRegistry([]), + $repositoryManager->reveal(), + ); + + $result = $provider->handlerForCommand(CreateProfile::class); + + self::assertCount(0, $result); + } + + public function testGetCreateHandler(): void + { + $repositoryManager = $this->prophesize(RepositoryManager::class); + + $provider = new AggregateHandlerProvider( + new AggregateRootRegistry(['profile' => ProfileWithHandler::class]), + $repositoryManager->reveal(), + ); + + $result = $provider->handlerForCommand(CreateProfile::class); + + $handler = new CreateAggregateHandler( + $repositoryManager->reveal(), + ProfileWithHandler::class, + 'create', + new DefaultParameterResolver(), + ); + + self::assertCount(1, $result); + self::assertEquals($handler->__invoke(...), $result[0]->callable()); + } + + public function testGetUpdateHandler(): void + { + $repositoryManager = $this->prophesize(RepositoryManager::class); + + $provider = new AggregateHandlerProvider( + new AggregateRootRegistry(['profile' => ProfileWithHandler::class]), + $repositoryManager->reveal(), + ); + + $result = $provider->handlerForCommand(ChangeProfileName::class); + + $handler = new UpdateAggregateHandler( + $repositoryManager->reveal(), + ProfileWithHandler::class, + 'updateName', + new DefaultParameterResolver(), + ); + + self::assertCount(1, $result); + self::assertEquals($handler->__invoke(...), $result[0]->callable()); + } +} diff --git a/tests/Unit/CommandBus/ChainHandlerProviderTest.php b/tests/Unit/CommandBus/ChainHandlerProviderTest.php new file mode 100644 index 000000000..9e4cbb4ac --- /dev/null +++ b/tests/Unit/CommandBus/ChainHandlerProviderTest.php @@ -0,0 +1,52 @@ +handlerForCommand(CreateProfile::class); + + self::assertCount(0, $result); + } + + public function testFindHandler(): void + { + $handler1 = new HandlerDescriptor(static fn () => null); + $handler2 = new HandlerDescriptor(static fn () => null); + $handler3 = new HandlerDescriptor(static fn () => null); + + $provider1 = $this->prophesize(HandlerProvider::class); + $provider1->handlerForCommand(CreateProfile::class)->willReturn([ + $handler1, + $handler2, + ]); + + $provider2 = $this->prophesize(HandlerProvider::class); + $provider2->handlerForCommand(CreateProfile::class)->willReturn([$handler3]); + + $chainProvider = new ChainHandlerProvider([ + $provider1->reveal(), + $provider2->reveal(), + ]); + + $result = $chainProvider->handlerForCommand(CreateProfile::class); + + self::assertSame([$handler1, $handler2, $handler3], $result); + } +} diff --git a/tests/Unit/CommandBus/Handler/CreateAggregateHandlerTest.php b/tests/Unit/CommandBus/Handler/CreateAggregateHandlerTest.php new file mode 100644 index 000000000..cc2ca379f --- /dev/null +++ b/tests/Unit/CommandBus/Handler/CreateAggregateHandlerTest.php @@ -0,0 +1,83 @@ +prophesize(Repository::class); + $repository->save(Argument::type(ProfileWithHandler::class))->shouldBeCalled(); + + $repositoryManager = $this->prophesize(RepositoryManager::class); + $repositoryManager + ->get(ProfileWithHandler::class) + ->willReturn($repository->reveal()) + ->shouldBeCalled(); + + $handler = new CreateAggregateHandler( + $repositoryManager->reveal(), + ProfileWithHandler::class, + 'create', + new DefaultParameterResolver(), + ); + + $command = new CreateProfile( + ProfileId::fromString('123'), + 'test', + ); + + $handler->__invoke($command); + } + + public function testNoAggregate(): void + { + $class = new class () { + public static function create(): string + { + return 'test'; + } + }; + + $repository = $this->prophesize(Repository::class); + + $repositoryManager = $this->prophesize(RepositoryManager::class); + $repositoryManager + ->get($class::class) + ->willReturn($repository->reveal()) + ->shouldBeCalled(); + + $handler = new CreateAggregateHandler( + $repositoryManager->reveal(), + $class::class, + 'create', + new DefaultParameterResolver(), + ); + + $command = new CreateProfile( + ProfileId::fromString('123'), + 'test', + ); + + $this->expectException(InvalidArgumentException::class); + + $handler->__invoke($command); + } +} diff --git a/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php b/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php new file mode 100644 index 000000000..46c66a168 --- /dev/null +++ b/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php @@ -0,0 +1,227 @@ +resolve( + new ReflectionMethod($class, 'handle'), + new stdClass(), + ), + ]; + + self::assertSame([], $result); + } + + public function testOnlyCommand(): void + { + $class = new class () { + public function handle(stdClass $command): void + { + } + }; + + $resolver = new DefaultParameterResolver(); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + + public function testMissingContainer(): void + { + $this->expectException(ServiceNotResolvable::class); + + $class = new class () { + // phpcs:disable + public function handle(stdClass $command, $foo): void + { + } + // phpcs:enable + }; + + $resolver = new DefaultParameterResolver(); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + + public function testNoType(): void + { + $this->expectException(ServiceNotResolvable::class); + + $class = new class () { + // phpcs:disable + public function handle(stdClass $command, $foo): void + { + } + // phpcs:enable + }; + + $container = $this->prophesize(ContainerInterface::class); + + $resolver = new DefaultParameterResolver($container->reveal()); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + + public function testNoClass(): void + { + $this->expectException(ServiceNotResolvable::class); + + $class = new class () { + public function handle(stdClass $command, string $foo): void + { + } + }; + + $container = $this->prophesize(ContainerInterface::class); + + $resolver = new DefaultParameterResolver($container->reveal()); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + + public function testMissingService(): void + { + $this->expectException(ServiceNotResolvable::class); + + $class = new class () { + public function handle(stdClass $command, stdClass $foo): void + { + } + }; + + $container = $this->prophesize(ContainerInterface::class); + $container->get(stdClass::class)->willThrow(new ServiceNotFound(stdClass::class))->shouldBeCalledOnce(); + + $resolver = new DefaultParameterResolver($container->reveal()); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + + public function testFindService(): void + { + $class = new class () { + public function handle(stdClass $command, stdClass $foo): void + { + } + }; + + $service = new stdClass(); + + $container = $this->prophesize(ContainerInterface::class); + $container->get(stdClass::class)->willReturn($service)->shouldBeCalledOnce(); + + $resolver = new DefaultParameterResolver($container->reveal()); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command, $service], $result); + } + + public function testInject(): void + { + $class = new class () { + public function handle( + stdClass $command, + #[Inject('foo')] + stdClass $foo, + ): void { + } + }; + + $service = new stdClass(); + + $container = $this->prophesize(ContainerInterface::class); + $container->get('foo')->willReturn($service)->shouldBeCalledOnce(); + + $resolver = new DefaultParameterResolver($container->reveal()); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command, $service], $result); + } +} diff --git a/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php b/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php new file mode 100644 index 000000000..990b90314 --- /dev/null +++ b/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php @@ -0,0 +1,74 @@ +prophesize(Repository::class); + $repository->load($profileId)->willReturn($profile)->shouldBeCalled(); + $repository->save($profile)->shouldBeCalled(); + + $repositoryManager = $this->prophesize(RepositoryManager::class); + $repositoryManager + ->get(ProfileWithHandler::class) + ->willReturn($repository->reveal()) + ->shouldBeCalled(); + + $handler = new UpdateAggregateHandler( + $repositoryManager->reveal(), + ProfileWithHandler::class, + 'changeName', + new DefaultParameterResolver(), + ); + + $command = new ChangeProfileName( + ProfileId::fromString('123'), + 'test', + ); + + $handler->__invoke($command); + } + + public function testMissingAggregateId(): void + { + $repositoryManager = $this->prophesize(RepositoryManager::class); + + $handler = new UpdateAggregateHandler( + $repositoryManager->reveal(), + ProfileWithHandler::class, + 'changeName', + new DefaultParameterResolver(), + ); + + $command = new CreateProfile( + ProfileId::fromString('123'), + 'test', + ); + + $this->expectException(AggregateIdNotFound::class); + + $handler->__invoke($command); + } +} diff --git a/tests/Unit/CommandBus/HandlerDescriptorTest.php b/tests/Unit/CommandBus/HandlerDescriptorTest.php new file mode 100644 index 000000000..c7174126f --- /dev/null +++ b/tests/Unit/CommandBus/HandlerDescriptorTest.php @@ -0,0 +1,59 @@ +changeName(...)); + + self::assertEquals($aggregate->changeName(...), $descriptor->callable()); + self::assertEquals('Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithHandler::changeName', $descriptor->name()); + } + + public function testStaticObjectMethod(): void + { + $descriptor = new HandlerDescriptor([ProfileWithHandler::class, 'create']); + + self::assertEquals(ProfileWithHandler::create(...), $descriptor->callable()); + self::assertEquals('Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithHandler::create', $descriptor->name()); + } + + #[RequiresPhp('>= 8.2')] + public function testAnonymousFunction(): void + { + $handler = static function (): void { + }; + + $descriptor = new HandlerDescriptor($handler(...)); + + self::assertEquals($handler(...), $descriptor->callable()); + self::assertEquals('Closure', $descriptor->name()); + } + + public function testAnonymousClass(): void + { + $handler = new class { + public function __invoke(): void + { + } + }; + + $descriptor = new HandlerDescriptor($handler->__invoke(...)); + + self::assertEquals($handler->__invoke(...), $descriptor->callable()); + self::assertStringContainsString('class@anonymous', $descriptor->name()); + self::assertStringContainsString(__FILE__, $descriptor->name()); + } +} diff --git a/tests/Unit/CommandBus/HandlerFinderTest.php b/tests/Unit/CommandBus/HandlerFinderTest.php new file mode 100644 index 000000000..09fecb3e1 --- /dev/null +++ b/tests/Unit/CommandBus/HandlerFinderTest.php @@ -0,0 +1,110 @@ +expectException(InvalidHandleMethod::class); + + $class = new class () { + #[Handle] + public function handle(): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + self::assertSame([], $result); + } + + public function testNoType(): void + { + $this->expectException(InvalidHandleMethod::class); + + $class = new class () { + // phpcs:disable + #[Handle] + public function handle($command): void + { + } + // phpcs:enable + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testWrongType(): void + { + $this->expectException(InvalidHandleMethod::class); + + $class = new class () { + #[Handle] + public function handle(string $command): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testEmpty(): void + { + $class = new class () { + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testWithCommandClass(): void + { + $class = new class () { + #[Handle(CreateProfile::class)] + public function handle(CreateProfile $command): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertEquals([ + new HandlerReference(CreateProfile::class, 'handle', false), + ], $result); + } + + public function testWithTypeGuessing(): void + { + $class = new class () { + #[Handle] + public function handle(CreateProfile $command): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertEquals([ + new HandlerReference(CreateProfile::class, 'handle', false), + ], $result); + } +} diff --git a/tests/Unit/CommandBus/ServiceHandlerProviderTest.php b/tests/Unit/CommandBus/ServiceHandlerProviderTest.php new file mode 100644 index 000000000..dd4c2e373 --- /dev/null +++ b/tests/Unit/CommandBus/ServiceHandlerProviderTest.php @@ -0,0 +1,59 @@ +handlerForCommand(CreateProfile::class); + + self::assertCount(0, $result); + } + + public function testFindHandler(): void + { + $class = new class () { + #[Handle(CreateProfile::class)] + public function handle(): void + { + } + }; + + $provider = new ServiceHandlerProvider([$class]); + + $result = $provider->handlerForCommand(CreateProfile::class); + + self::assertCount(1, $result); + self::assertEquals($class->handle(...), $result[0]->callable()); + } + + public function testFindStaticHandler(): void + { + $class = new class () { + #[Handle(CreateProfile::class)] + public static function handle(): void + { + } + }; + + $provider = new ServiceHandlerProvider([$class]); + + $result = $provider->handlerForCommand(CreateProfile::class); + + self::assertCount(1, $result); + self::assertEquals($class::handle(...), $result[0]->callable()); + } +} diff --git a/tests/Unit/CommandBus/ServiceLocatorTest.php b/tests/Unit/CommandBus/ServiceLocatorTest.php new file mode 100644 index 000000000..fc732e917 --- /dev/null +++ b/tests/Unit/CommandBus/ServiceLocatorTest.php @@ -0,0 +1,56 @@ + $service, + ClockInterface::class => $service, + ]); + + self::assertSame($service, $serviceLocator->get('clock')); + self::assertSame($service, $serviceLocator->get(ClockInterface::class)); + } + + public function testNotFound(): void + { + $service = new SystemClock(); + + $serviceLocator = new ServiceLocator([ + 'clock' => $service, + ClockInterface::class => $service, + ]); + + $this->expectException(ServiceNotFound::class); + + $serviceLocator->get('foo'); + } + + public function testHasService(): void + { + $service = new SystemClock(); + + $serviceLocator = new ServiceLocator([ + 'clock' => $service, + ClockInterface::class => $service, + ]); + + self::assertTrue($serviceLocator->has('clock')); + self::assertTrue($serviceLocator->has(ClockInterface::class)); + self::assertFalse($serviceLocator->has('foo')); + } +} diff --git a/tests/Unit/CommandBus/SyncCommandBusTest.php b/tests/Unit/CommandBus/SyncCommandBusTest.php new file mode 100644 index 000000000..c70ebeb30 --- /dev/null +++ b/tests/Unit/CommandBus/SyncCommandBusTest.php @@ -0,0 +1,78 @@ +prophesize(HandlerProvider::class); + $handlerProvider->handlerForCommand($command::class)->willReturn([]); + + $commandBus = new SyncCommandBus($handlerProvider->reveal()); + + $this->expectException(HandlerNotFound::class); + + $commandBus->dispatch($command); + } + + public function testMultipleHandlersFound(): void + { + $command = new class { + }; + + $handlerProvider = $this->prophesize(HandlerProvider::class); + $handlerProvider->handlerForCommand($command::class)->willReturn([ + new HandlerDescriptor(static fn () => null), + new HandlerDescriptor(static fn () => null), + ]); + + $commandBus = new SyncCommandBus($handlerProvider->reveal()); + + $this->expectException(MultipleHandlersFound::class); + + $commandBus->dispatch($command); + } + + public function testHandleSuccess(): void + { + $command = new class { + }; + + $handler = new class { + public object|null $command = null; + + public function __invoke(object $command): void + { + $this->command = $command; + } + }; + + $handlerProvider = $this->prophesize(HandlerProvider::class); + $handlerProvider->handlerForCommand($command::class)->willReturn([ + new HandlerDescriptor($handler), + ]); + + $commandBus = new SyncCommandBus($handlerProvider->reveal()); + + $commandBus->dispatch($command); + + self::assertSame($command, $handler->command); + } +} diff --git a/tests/Unit/Fixture/ActivateProfile.php b/tests/Unit/Fixture/ActivateProfile.php new file mode 100644 index 000000000..764cc5501 --- /dev/null +++ b/tests/Unit/Fixture/ActivateProfile.php @@ -0,0 +1,16 @@ +