diff --git a/composer.json b/composer.json index aeb9f160..e58a4ddd 100644 --- a/composer.json +++ b/composer.json @@ -21,14 +21,14 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "patchlevel/event-sourcing": "4.0.x-dev", "patchlevel/hydrator": "^2.0.2", - "symfony/cache": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/config": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/console": "^6.4.1 || ^7.0.1 || ^8.0.0", - "symfony/dependency-injection": "^6.4.1 || ^7.0.1 || ^8.0.0", - "symfony/event-dispatcher": "^6.4.1 || ^7.0.1 || ^8.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/http-kernel": "^6.4.1 || ^7.0.1 || ^8.0.0", - "symfony/messenger": "^6.4.0 || ^7.0.0 || ^8.0.0", + "symfony/cache": "^7.3.0 || ^8.0.0", + "symfony/config": "^7.3.0 || ^8.0.0", + "symfony/console": "^7.3.0 || ^8.0.0", + "symfony/dependency-injection": "^7.3.0 || ^8.0.0", + "symfony/event-dispatcher": "^7.3.0 || ^8.0.0", + "symfony/finder": "^7.3.0 || ^8.0.0", + "symfony/http-kernel": "^7.3.0 || ^8.0.0", + "symfony/messenger": "^7.3.0 || ^8.0.0", "symfony/service-contracts": "^2.5 || ^3.0" }, "require-dev": { @@ -42,10 +42,10 @@ "phpstan/phpstan-symfony": "^2.0.8", "phpunit/phpunit": "^11.5.45", "roave/security-advisories": "dev-master", - "symfony/clock": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/uid": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/var-dumper": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/web-profiler-bundle": "^6.4.0 || ^7.0.0 || ^8.0.0", + "symfony/clock": "^7.3.0 || ^8.0.0", + "symfony/uid": "^7.3.0 || ^8.0.0", + "symfony/var-dumper": "^7.3.0 || ^8.0.0", + "symfony/web-profiler-bundle": "^7.3.0 || ^8.0.0", "wnx/commonmark-markdown-renderer": "^1.4" }, "suggest": { @@ -55,7 +55,7 @@ }, "conflict": { "doctrine/doctrine-bundle": "<2.8", - "symfony/event-dispatcher": "6.4.36 || 7.4.8 || 8.0.8" + "symfony/event-dispatcher": "7.4.8 || 8.0.8" }, "config": { "preferred-install": { diff --git a/composer.lock b/composer.lock index eeaeeaba..3de774f9 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": "e8e556d60159c0d8a9127c205fd6e149", + "content-hash": "6e0134267927896dc554ae6c3f44dcd2", "packages": [ { "name": "brick/math", @@ -420,12 +420,12 @@ "source": { "type": "git", "url": "https://github.com/patchlevel/event-sourcing.git", - "reference": "ade8292ca34e1f8c398e31e4a43c01973548f757" + "reference": "51c07650652266b11c641a3a25d9955757bd3b31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/ade8292ca34e1f8c398e31e4a43c01973548f757", - "reference": "ade8292ca34e1f8c398e31e4a43c01973548f757", + "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/51c07650652266b11c641a3a25d9955757bd3b31", + "reference": "51c07650652266b11c641a3a25d9955757bd3b31", "shasum": "" }, "require": { @@ -507,7 +507,7 @@ "issues": "https://github.com/patchlevel/event-sourcing/issues", "source": "https://github.com/patchlevel/event-sourcing/tree/4.0.x" }, - "time": "2026-09-24T06:22:35+00:00" + "time": "2026-10-03T10:26:29+00:00" }, { "name": "patchlevel/hydrator", diff --git a/docs/configuration.md b/docs/configuration.md index 285fc9c1..d51726d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,72 +10,33 @@ This documentation is limited to bundle integration and configuration. We provide a [default configuration](./installation.md#configuration-file) that should work for most projects. ::: -## Aggregate +## Aggregates, Events and Headers -A path must be specified for Event Sourcing to know where to look for your aggregates. -If you want you can use glob patterns to specify multiple paths. +Aggregates, events and custom headers are discovered via Symfony's service autoconfiguration. +Every class marked with the `#[Aggregate]`, `#[Event]` or `#[Header]` attribute that is covered +by a service resource with `autoconfigure` enabled is registered automatically. +These classes are not registered as services, they are only collected and then excluded from the container. -```yaml -patchlevel_event_sourcing: - aggregates: '%kernel.project_dir%/src/*/Domain' -``` -Or use an array to specify multiple paths. +With the default Symfony configuration in `config/services.yaml` this works out of the box: ```yaml -patchlevel_event_sourcing: - aggregates: - - '%kernel.project_dir%/src/Hotel/Domain' - - '%kernel.project_dir%/src/Room/Domain' -``` -:::note -The library will automatically register all classes marked with the `#[Aggregate]` attribute in the specified paths. -::: +services: + _defaults: + autowire: true + autoconfigure: true -:::tip -If you want to learn more about aggregates, read the [library documentation](https://event-sourcing.patchlevel.io/latest/aggregate/). -::: - -## Events - -A path must be specified for Event Sourcing to know where to look for your events. -If you want you can use glob patterns to specify multiple paths. - -```yaml -patchlevel_event_sourcing: - events: '%kernel.project_dir%/src/*/Domain/Event' + App\: + resource: '../src/' ``` -Or use an array to specify multiple paths. - -```yaml -patchlevel_event_sourcing: - events: - - '%kernel.project_dir%/src/Hotel/Domain/Event' - - '%kernel.project_dir%/src/Room/Domain/Event' -``` -:::tip -If you want to learn more about events, read the [library documentation](https://event-sourcing.patchlevel.io/latest/events/). +:::note +Make sure the directories containing your aggregates, events and headers are not listed in the `exclude` option. ::: -## Custom Headers - -If you want to implement custom headers for your application, you must specify the -paths to look for those headers. -If you want you can use glob patterns to specify multiple paths. - -```yaml -patchlevel_event_sourcing: - headers: '%kernel.project_dir%/src/*/Domain/Header' -``` -Or use an array to specify multiple paths. - -```yaml -patchlevel_event_sourcing: - headers: - - '%kernel.project_dir%/src/Hotel/Domain/Header' - - '%kernel.project_dir%/src/Room/Domain/Header' -``` :::tip -If you want to learn more about custom headers, read the [library documentation](https://event-sourcing.patchlevel.io/latest/message/#custom-headers). +If you want to learn more about [aggregates](https://event-sourcing.patchlevel.io/latest/aggregate/), +[events](https://event-sourcing.patchlevel.io/latest/events/) +or [custom headers](https://event-sourcing.patchlevel.io/latest/message/#custom-headers), +read the library documentation. ::: ## Connection diff --git a/docs/installation.md b/docs/installation.md index bef677db..da4c21d4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -32,8 +32,6 @@ Now you have to add following recommended configuration file here `config/packag ```yaml patchlevel_event_sourcing: - aggregates: '%kernel.project_dir%/src' - events: '%kernel.project_dir%/src' connection: url: '%env(EVENTSTORE_URL)%' provide_dedicated_connection: true diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9eb870ca..55599374 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,18 +54,6 @@ parameters: count: 1 path: tests/Fixtures/SnapshotableProfile.php - - - message: '#^Call to an undefined method Symfony\\Component\\DependencyInjection\\ContainerBuilder\:\:getAutoconfiguredAttributes\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Unit/PatchlevelEventSourcingBundleTest.php - - - - message: '#^Cannot access offset ''Patchlevel\\\\EventSourcing\\\\Attribute\\\\Aggregate''\|''Patchlevel\\\\EventSourcing\\\\Attribute\\\\Event'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Unit/PatchlevelEventSourcingBundleTest.php - - message: '#^Cannot access offset ''bus'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -83,9 +71,3 @@ parameters: identifier: offsetAccess.nonOffsetAccessible count: 2 path: tests/Unit/PatchlevelEventSourcingBundleTest.php - - - - message: '#^Trying to invoke mixed but it''s not a callable\.$#' - identifier: callable.nonCallable - count: 1 - path: tests/Unit/PatchlevelEventSourcingBundleTest.php diff --git a/src/DependencyInjection/CommandHandlerCompilerPass.php b/src/DependencyInjection/CommandHandlerCompilerPass.php index a078c79b..81b85dbd 100644 --- a/src/DependencyInjection/CommandHandlerCompilerPass.php +++ b/src/DependencyInjection/CommandHandlerCompilerPass.php @@ -7,7 +7,6 @@ use Patchlevel\EventSourcing\CommandBus\Handler\CreateAggregateHandler; use Patchlevel\EventSourcing\CommandBus\Handler\UpdateAggregateHandler; use Patchlevel\EventSourcing\CommandBus\HandlerFinder; -use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\RepositoryManager; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -30,10 +29,10 @@ public function process(ContainerBuilder $container): void $container->getParameter('patchlevel_event_sourcing.aggregate_handlers.bus'), ); - /** @var AggregateRootRegistry $aggregateRootRegistry */ - $aggregateRootRegistry = $container->get(AggregateRootRegistry::class); + /** @var array $aggregates */ + $aggregates = $container->getParameter('event_sourcing.aggregates'); - foreach ($aggregateRootRegistry->aggregateClasses() as $aggregateName => $aggregateClass) { + foreach ($aggregates as $aggregateName => $aggregateClass) { $parameterResolverId = sprintf('.event_sourcing.handler_parameter_resolver.%s', $aggregateName); foreach (HandlerFinder::findInClass($aggregateClass) as $aggregateHandler) { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e16f314e..165dcce6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -74,9 +74,6 @@ * translators: list * } * }, - * aggregates: list, - * events: list, - * headers: list, * snapshot_stores: array, * migration: array{path: string, namespace: string}, * clock: array{freeze: ?string, service: ?string}, @@ -156,24 +153,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() - ->arrayNode('events') - ->beforeNormalization()->castToArray()->end() - ->defaultValue([]) - ->scalarPrototype()->end() - ->end() - - ->arrayNode('aggregates') - ->beforeNormalization()->castToArray()->end() - ->defaultValue([]) - ->scalarPrototype()->end() - ->end() - - ->arrayNode('headers') - ->beforeNormalization()->castToArray()->end() - ->defaultValue([]) - ->scalarPrototype()->end() - ->end() - ->arrayNode('clock') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/HandlerServiceLocatorCompilerPass.php b/src/DependencyInjection/HandlerServiceLocatorCompilerPass.php index 87eb52a6..e7f9d903 100644 --- a/src/DependencyInjection/HandlerServiceLocatorCompilerPass.php +++ b/src/DependencyInjection/HandlerServiceLocatorCompilerPass.php @@ -7,7 +7,6 @@ use Patchlevel\EventSourcing\Attribute\Inject; use Patchlevel\EventSourcing\CommandBus\Handler\ServiceNotResolvable; use Patchlevel\EventSourcing\CommandBus\HandlerFinder; -use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcingBundle\CommandBus\SymfonyParameterResolver; use Psr\Container\ContainerInterface; use ReflectionAttribute; @@ -32,10 +31,10 @@ public function process(ContainerBuilder $container): void return; } - /** @var AggregateRootRegistry $aggregateRootRegistry */ - $aggregateRootRegistry = $container->get(AggregateRootRegistry::class); + /** @var array $aggregates */ + $aggregates = $container->getParameter('event_sourcing.aggregates'); - foreach ($aggregateRootRegistry->aggregateClasses() as $aggregateName => $aggregateClass) { + foreach ($aggregates as $aggregateName => $aggregateClass) { $parameterResolverId = sprintf('.event_sourcing.handler_parameter_resolver.%s', $aggregateName); $services = []; diff --git a/src/DependencyInjection/PatchlevelEventSourcingExtension.php b/src/DependencyInjection/PatchlevelEventSourcingExtension.php index 22e730b7..765a9e01 100644 --- a/src/DependencyInjection/PatchlevelEventSourcingExtension.php +++ b/src/DependencyInjection/PatchlevelEventSourcingExtension.php @@ -17,6 +17,7 @@ use Doctrine\ORM\Tools\ToolEvents; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\Header; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscriber; @@ -61,14 +62,10 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataAwareMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; -use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; -use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; -use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; -use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; use Patchlevel\EventSourcing\QueryBus\QueryBus; @@ -154,6 +151,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Parameter; use Symfony\Component\DependencyInjection\Reference; use function class_exists; @@ -165,8 +163,6 @@ final class PatchlevelEventSourcingExtension extends Extension /** @param array $configs */ public function load(array $configs, ContainerBuilder $container): void { - $this->removeNonServices($container); - $configuration = new Configuration(); /** @var Config $config */ @@ -186,7 +182,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->configureConnection($config, $container); $this->configureStore($config, $container); $this->configureSnapshots($config, $container); - $this->configureAggregates($config, $container); + $this->configureAggregates($container); $this->configureCommands($container); $this->configureProfiler($container); $this->configureClock($config, $container); @@ -202,11 +198,18 @@ public function load(array $configs, ContainerBuilder $container): void /** @param Config $config */ private function configureSerializer(array $config, ContainerBuilder $container): void { - $container->register(AttributeEventRegistryFactory::class); + $container->registerAttributeForAutoconfiguration( + Event::class, + static function (ChildDefinition $definition, Event $attribute): void { + $definition->addResourceTag('event_sourcing.event', [ + 'name' => $attribute->name, + 'aliases' => $attribute->aliases, + ]); + }, + ); $container->register(EventRegistry::class) - ->setFactory([new Reference(AttributeEventRegistryFactory::class), 'create']) - ->setArguments([$config['events']]); + ->setArguments([new Parameter('event_sourcing.events')]); $container->register(AttributeEventMetadataFactory::class); $container->setAlias(EventMetadataFactory::class, AttributeEventMetadataFactory::class); @@ -223,12 +226,18 @@ private function configureSerializer(array $config, ContainerBuilder $container) $container->setAlias(EventSerializer::class, DefaultEventSerializer::class); - $container->register(AttributeMessageHeaderRegistryFactory::class); - $container->setAlias(MessageHeaderRegistryFactory::class, AttributeMessageHeaderRegistryFactory::class); + $container->registerAttributeForAutoconfiguration( + Header::class, + static function (ChildDefinition $definition, Header $attribute): void { + $definition->addResourceTag('event_sourcing.header', [ + 'name' => $attribute->name, + 'aliases' => $attribute->aliases, + ]); + }, + ); $container->register(MessageHeaderRegistry::class) - ->setFactory([new Reference(MessageHeaderRegistryFactory::class), 'create']) - ->setArguments([$config['headers']]); + ->setArguments([new Parameter('event_sourcing.headers')]); $container->register(DefaultHeadersSerializer::class) ->setArguments([ @@ -884,17 +893,20 @@ private function configureSnapshots(array $config, ContainerBuilder $container): $container->setAlias(SnapshotStore::class, DefaultSnapshotStore::class); } - /** @param Config $config */ - private function configureAggregates(array $config, ContainerBuilder $container): void + private function configureAggregates(ContainerBuilder $container): void { + $container->registerAttributeForAutoconfiguration( + Aggregate::class, + static function (ChildDefinition $definition, Aggregate $attribute): void { + $definition->addResourceTag('event_sourcing.aggregate', ['name' => $attribute->name]); + }, + ); + $container->register(AggregateRootMetadataAwareMetadataFactory::class); $container->setAlias(AggregateRootMetadataFactory::class, AggregateRootMetadataAwareMetadataFactory::class); - $container->register(AttributeAggregateRootRegistryFactory::class); - $container->register(AggregateRootRegistry::class) - ->setFactory([new Reference(AttributeAggregateRootRegistryFactory::class), 'create']) - ->setArguments([$config['aggregates']]); + ->setArguments([new Parameter('event_sourcing.aggregates')]); $container->register(DefaultRepositoryManager::class) ->setArguments([ @@ -1198,26 +1210,4 @@ private function configureValueResolver(ContainerBuilder $container): void $container->register(IdentifierValueResolver::class) ->addTag('controller.argument_value_resolver', ['priority' => 200]); } - - private function removeNonServices(ContainerBuilder $container): void - { - $container->registerAttributeForAutoconfiguration( - Aggregate::class, - static function (ChildDefinition $definition): void { - $definition->setAbstract(true)->addTag( - 'container.excluded', - ['source' => sprintf('with #[%s] attribute', Aggregate::class)], - ); - }, - ); - $container->registerAttributeForAutoconfiguration( - Event::class, - static function (ChildDefinition $definition): void { - $definition->setAbstract(true)->addTag( - 'container.excluded', - ['source' => sprintf('with #[%s] attribute', Event::class)], - ); - }, - ); - } } diff --git a/src/DependencyInjection/RepositoryCompilerPass.php b/src/DependencyInjection/RepositoryCompilerPass.php index 83960f4d..8233fe2c 100644 --- a/src/DependencyInjection/RepositoryCompilerPass.php +++ b/src/DependencyInjection/RepositoryCompilerPass.php @@ -4,12 +4,10 @@ namespace Patchlevel\EventSourcingBundle\DependencyInjection; -use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Repository\RepositoryManager; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; @@ -18,20 +16,17 @@ final class RepositoryCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - $aggregateRootRegistry = $container->get(AggregateRootRegistry::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); + /** @var array $aggregates */ + $aggregates = $container->getParameter('event_sourcing.aggregates'); - if (!$aggregateRootRegistry instanceof AggregateRootRegistry) { - return; - } - - foreach ($aggregateRootRegistry->aggregateNames() as $aggregateName) { + foreach ($aggregates as $aggregateName => $aggregateClass) { $aggregateRepositoryName = $aggregateName . 'Repository'; $aggregateRepositoryId = 'event_sourcing.' . $aggregateName . '.repository'; $definition = new Definition(Repository::class); $definition->setPublic(false); $definition->setFactory([new Reference(RepositoryManager::class), 'get']); - $definition->setArgument(0, $aggregateRootRegistry->aggregateClass($aggregateName)); + $definition->setArgument(0, $aggregateClass); $container->setDefinition($aggregateRepositoryId, $definition); $container->registerAliasForArgument($aggregateRepositoryId, Repository::class, $aggregateRepositoryName)->setPublic(false); diff --git a/src/DependencyInjection/ResourceCompilerPass.php b/src/DependencyInjection/ResourceCompilerPass.php new file mode 100644 index 00000000..288ae95d --- /dev/null +++ b/src/DependencyInjection/ResourceCompilerPass.php @@ -0,0 +1,95 @@ +taggedClasses($container, 'event_sourcing.aggregate') as [$class, $attribute]) { + if (!is_subclass_of($class, AggregateRoot::class)) { + throw new NoAggregateRoot($class); + } + + if (array_key_exists($attribute['name'], $aggregates)) { + throw new AggregateRootAlreadyInRegistry($attribute['name']); + } + + $aggregates[$attribute['name']] = $class; + } + + $events = []; + + foreach ($this->taggedClasses($container, 'event_sourcing.event') as [$class, $attribute]) { + foreach ([$attribute['name'], ...$attribute['aliases'] ?? []] as $name) { + if (array_key_exists($name, $events)) { + throw new EventAlreadyInRegistry($name); + } + + $events[$name] = $class; + } + } + + $headers = []; + $headerAliases = []; + + foreach ($this->taggedClasses($container, 'event_sourcing.header') as [$class, $attribute]) { + $headers[$attribute['name']] = $class; + + foreach ($attribute['aliases'] ?? [] as $alias) { + if (array_key_exists($alias, $headerAliases)) { + throw new HeaderAlreadyInRegistry($alias); + } + + $headerAliases[$alias] = $class; + } + } + + $headers = MessageHeaderRegistry::createWithInternalHeaders($headers)->headerClasses(); + + foreach ($headerAliases as $alias => $class) { + if (array_key_exists($alias, $headers)) { + throw new HeaderAlreadyInRegistry($alias); + } + + $headers[$alias] = $class; + } + + $container->setParameter('event_sourcing.aggregates', $aggregates); + $container->setParameter('event_sourcing.events', $events); + $container->setParameter('event_sourcing.headers', $headers); + } + + /** @return iterable}}> */ + private function taggedClasses(ContainerBuilder $container, string $tag): iterable + { + /** @var array}>> $taggedResources */ + $taggedResources = $container->findTaggedResourceIds($tag); + + foreach ($taggedResources as $id => $attributes) { + /** @var class-string $class */ + $class = $container->getDefinition($id)->getClass() ?? $id; + + foreach ($attributes as $attribute) { + yield [$class, $attribute]; + } + } + } +} diff --git a/src/PatchlevelEventSourcingBundle.php b/src/PatchlevelEventSourcingBundle.php index 78a641e5..b4e74a89 100644 --- a/src/PatchlevelEventSourcingBundle.php +++ b/src/PatchlevelEventSourcingBundle.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcingBundle\DependencyInjection\HydratorCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\QueryHandlerCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\RepositoryCompilerPass; +use Patchlevel\EventSourcingBundle\DependencyInjection\ResourceCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\SubscriberGuardCompilePass; use Patchlevel\EventSourcingBundle\DependencyInjection\TranslatorCompilerPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -19,6 +20,7 @@ final class PatchlevelEventSourcingBundle extends Bundle { public function build(ContainerBuilder $container): void { + $container->addCompilerPass(new ResourceCompilerPass(), priority: 100); $container->addCompilerPass(new RepositoryCompilerPass()); $container->addCompilerPass(new SubscriberGuardCompilePass()); $container->addCompilerPass(new CommandHandlerCompilerPass(), priority: 100); diff --git a/tests/Fixtures/CustomHeader.php b/tests/Fixtures/CustomHeader.php index 1c1dfe97..b74ac392 100644 --- a/tests/Fixtures/CustomHeader.php +++ b/tests/Fixtures/CustomHeader.php @@ -6,7 +6,7 @@ use Patchlevel\EventSourcing\Attribute\Header; -#[Header('custom')] +#[Header('custom', aliases: ['legacyCustom'])] class CustomHeader { public function __construct( diff --git a/tests/Fixtures/NoAggregate.php b/tests/Fixtures/NoAggregate.php new file mode 100644 index 00000000..1f501790 --- /dev/null +++ b/tests/Fixtures/NoAggregate.php @@ -0,0 +1,12 @@ +has(Store::class)); } - #[RequiresMethod(ContainerBuilder::class, 'getAttributeAutoconfigurators')] public function testMinimalConfig(): void { $container = new ContainerBuilder(); @@ -177,57 +176,6 @@ public function testMinimalConfig(): void self::assertInstanceOf(AttributeEventTagExtractor::class, $container->get(EventTagExtractor::class)); self::assertFalse($container->has(EventBus::class)); - - $attributes = $container->getAttributeAutoconfigurators(); - foreach ([Aggregate::class, Event::class] as $class) { - $definition = new ChildDefinition(''); - - foreach ($attributes[$class] as $attributeCallable) { - $attributeCallable($definition); - } - - $this->assertSame( - [['source' => sprintf('with #[%s] attribute', $class)]], - $definition->getTag('container.excluded'), - ); - $this->assertTrue($definition->isAbstract()); - } - } - - #[RequiresMethod(ContainerBuilder::class, 'getAutoconfiguredAttributes')] - public function testMinimalConfigPreSymf8(): void - { - $container = new ContainerBuilder(); - $this->compileContainer( - $container, - [ - 'patchlevel_event_sourcing' => [ - 'connection' => ['url' => 'sqlite3:///:memory:'], - ], - ], - ); - - self::assertInstanceOf(Connection::class, $container->get('event_sourcing.dbal_connection')); - self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); - self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); - self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); - self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); - self::assertInstanceOf(SystemClock::class, $container->get('event_sourcing.clock')); - self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); - - self::assertFalse($container->has(EventBus::class)); - - $attributes = $container->getAutoconfiguredAttributes(); - foreach ([Aggregate::class, Event::class] as $class) { - $definition = new ChildDefinition(''); - $attributes[$class]($definition); - - $this->assertSame( - [['source' => sprintf('with #[%s] attribute', $class)]], - $definition->getTag('container.excluded'), - ); - $this->assertTrue($definition->isAbstract()); - } } public function testConnectionService(): void @@ -539,12 +487,14 @@ public function testCommandHandler(): void { $container = new ContainerBuilder(); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], 'command_bus' => ['service' => 'command.bus'], ], ], @@ -569,12 +519,14 @@ public function testCommandBus(): void { $container = new ContainerBuilder(); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], 'command_bus' => ['service' => 'command.bus'], ], ], @@ -608,7 +560,6 @@ public function testQueryBus(): void [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], 'query_bus' => ['service' => 'query.bus'], ], ], @@ -874,12 +825,14 @@ public function testEventRegistry(): void { $container = new ContainerBuilder(); + $container->setDefinition(ProfileCreated::class, new Definition(ProfileCreated::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'events' => [__DIR__ . '/../Fixtures'], ], ], ); @@ -888,18 +841,105 @@ public function testEventRegistry(): void self::assertInstanceOf(EventRegistry::class, $eventRegistry); self::assertTrue($eventRegistry->hasEventClass(ProfileCreated::class)); + self::assertSame('profile.created', $eventRegistry->eventName(ProfileCreated::class)); + self::assertSame(ProfileCreated::class, $eventRegistry->eventClass('profile.registered')); + } + + public function testDuplicateEventName(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition('event.a', new Definition(ProfileCreated::class)) + ->setAutoconfigured(true); + $container->setDefinition('event.b', new Definition(ProfileCreated::class)) + ->setAutoconfigured(true); + + $this->expectException(EventAlreadyInRegistry::class); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); } public function testAggregateRegistry(): void { $container = new ContainerBuilder(); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + + $aggregateRegistry = $container->get(AggregateRootRegistry::class); + + self::assertInstanceOf(AggregateRootRegistry::class, $aggregateRegistry); + self::assertTrue($aggregateRegistry->hasAggregateClass(Profile::class)); + } + + public function testDuplicateAggregateName(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition('aggregate.a', new Definition(Profile::class)) + ->setAutoconfigured(true); + $container->setDefinition('aggregate.b', new Definition(Profile::class)) + ->setAutoconfigured(true); + + $this->expectException(AggregateRootAlreadyInRegistry::class); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + } + + public function testAggregateWithoutAggregateRoot(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition(NoAggregate::class, new Definition(NoAggregate::class)) + ->setAutoconfigured(true); + + $this->expectException(NoAggregateRoot::class); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + } + + public function testAggregateRegistryWithCustomServiceId(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition('app.profile', new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], ], ], ); @@ -914,12 +954,14 @@ public function testMessageHeaderRegistry(): void { $container = new ContainerBuilder(); + $container->setDefinition(CustomHeader::class, new Definition(CustomHeader::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'headers' => [__DIR__ . '/../Fixtures'], ], ], ); @@ -929,18 +971,63 @@ public function testMessageHeaderRegistry(): void self::assertInstanceOf(MessageHeaderRegistry::class, $messageHeaderRegistry); self::assertTrue($messageHeaderRegistry->hasHeaderClass(CustomHeader::class)); + self::assertTrue($messageHeaderRegistry->hasHeaderName('playhead')); + self::assertSame('custom', $messageHeaderRegistry->headerName(CustomHeader::class)); + self::assertSame(CustomHeader::class, $messageHeaderRegistry->headerClass('legacyCustom')); + } + + public function testDuplicateHeaderAlias(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition('header.a', new Definition(CustomHeader::class)) + ->setAutoconfigured(true); + $container->setDefinition('header.b', new Definition(CustomHeader::class)) + ->setAutoconfigured(true); + + $this->expectException(HeaderAlreadyInRegistry::class); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + } + + public function testHeaderAliasCollidesWithInternalHeader(): void + { + $container = new ContainerBuilder(); + + $container->setDefinition(PlayheadAliasHeader::class, new Definition(PlayheadAliasHeader::class)) + ->setAutoconfigured(true); + + $this->expectException(HeaderAlreadyInRegistry::class); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); } public function testRepositoryManager(): void { $container = new ContainerBuilder(); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], ], ], ); @@ -1425,6 +1512,9 @@ public function testFullBuild(): void $container = new ContainerBuilder(); $container->set('clock', $psrClock); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ @@ -1435,7 +1525,6 @@ public function testFullBuild(): void ], 'clock' => ['service' => 'clock'], 'event_bus' => ['type' => 'default'], - 'aggregates' => [__DIR__ . '/../Fixtures'], 'migration' => [ 'namespace' => 'Foo', 'path' => 'src', @@ -1468,12 +1557,14 @@ public function testNamedRepository(): void { $container = new ContainerBuilder(); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $this->compileContainer( $container, [ 'patchlevel_event_sourcing' => [ 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], - 'aggregates' => [__DIR__ . '/../Fixtures'], ], ], );