diff --git a/composer.json b/composer.json index e58a4ddd..b1663b06 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "require": { "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", + "patchlevel/hydrator": "^2.0.3", "symfony/cache": "^7.3.0 || ^8.0.0", "symfony/config": "^7.3.0 || ^8.0.0", "symfony/console": "^7.3.0 || ^8.0.0", diff --git a/composer.lock b/composer.lock index 502e98ac..9fbafa0f 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": "6e0134267927896dc554ae6c3f44dcd2", + "content-hash": "6a6604c75d21e691e0f8d598b6baef77", "packages": [ { "name": "brick/math", @@ -420,19 +420,19 @@ "source": { "type": "git", "url": "https://github.com/patchlevel/event-sourcing.git", - "reference": "542b287b76c17450c3c049989dcb7a4864c99877" + "reference": "106c90ee5df01a3a06a8bef0823ac7fc16999297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/542b287b76c17450c3c049989dcb7a4864c99877", - "reference": "542b287b76c17450c3c049989dcb7a4864c99877", + "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/106c90ee5df01a3a06a8bef0823ac7fc16999297", + "reference": "106c90ee5df01a3a06a8bef0823ac7fc16999297", "shasum": "" }, "require": { "doctrine/dbal": "^4.4.0", "doctrine/migrations": "^3.3.2", "patchlevel/hydrator": "^2.0.2", - "patchlevel/worker": "^1.4.0", + "patchlevel/worker": "^1.7.0", "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/clock": "^1.0", @@ -507,20 +507,20 @@ "issues": "https://github.com/patchlevel/event-sourcing/issues", "source": "https://github.com/patchlevel/event-sourcing/tree/4.0.x" }, - "time": "2026-10-05T07:55:57+00:00" + "time": "2026-10-09T12:15:13+00:00" }, { "name": "patchlevel/hydrator", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "73aee5f6d9e35ba074ff2d4ade17cb1429475af7" + "reference": "9d6b80571097daaaa00cdeb9cd6248dc2985885e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/73aee5f6d9e35ba074ff2d4ade17cb1429475af7", - "reference": "73aee5f6d9e35ba074ff2d4ade17cb1429475af7", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/9d6b80571097daaaa00cdeb9cd6248dc2985885e", + "reference": "9d6b80571097daaaa00cdeb9cd6248dc2985885e", "shasum": "" }, "require": { @@ -573,22 +573,22 @@ ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/2.0.2" + "source": "https://github.com/patchlevel/hydrator/tree/2.0.3" }, - "time": "2026-09-23T17:48:35+00:00" + "time": "2026-10-09T11:56:42+00:00" }, { "name": "patchlevel/worker", - "version": "1.6.0", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/patchlevel/worker.git", - "reference": "eae43edaeb4a96f275c7e8ed05dfa95fc1a3ee9c" + "reference": "100b0b2b960c8cd32a6e5a717777d56d81791425" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/worker/zipball/eae43edaeb4a96f275c7e8ed05dfa95fc1a3ee9c", - "reference": "eae43edaeb4a96f275c7e8ed05dfa95fc1a3ee9c", + "url": "https://api.github.com/repos/patchlevel/worker/zipball/100b0b2b960c8cd32a6e5a717777d56d81791425", + "reference": "100b0b2b960c8cd32a6e5a717777d56d81791425", "shasum": "" }, "require": { @@ -639,9 +639,9 @@ ], "support": { "issues": "https://github.com/patchlevel/worker/issues", - "source": "https://github.com/patchlevel/worker/tree/1.6.0" + "source": "https://github.com/patchlevel/worker/tree/1.7.0" }, - "time": "2026-10-02T17:54:07+00:00" + "time": "2026-10-05T07:23:26+00:00" }, { "name": "psr/cache", diff --git a/docs/configuration.md b/docs/configuration.md index 4b65db59..474efd1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,21 @@ or [custom headers](/docs/event-sourcing/latest/message/#custom-headers), read the library documentation. ::: +## Metadata Cache + +Aggregates, events and subscribers are described with attributes. +The aggregates, events and headers are already collected when the container is compiled, +but their metadata is read via reflection at runtime. +In production, meaning without `kernel.debug`, this metadata is cached +in php files in the build directory, which are created by `cache:warmup`. +This includes the hydrator metadata of your events, headers and aggregates with snapshots. +Until then, it is created on every request. +There is nothing to configure, the cache is renewed with every new build, e.g. by `cache:clear`. + +:::note +With `kernel.debug`, nothing is cached, so that changes to your classes are picked up immediately. +::: + ## Connection You have to specify the connection url to the event store. diff --git a/src/CacheWarmer/HydratorMetadataCacheWarmer.php b/src/CacheWarmer/HydratorMetadataCacheWarmer.php new file mode 100644 index 00000000..a1281a21 --- /dev/null +++ b/src/CacheWarmer/HydratorMetadataCacheWarmer.php @@ -0,0 +1,81 @@ + classes to preload */ + public function warmUp(string $cacheDir, string|null $buildDir = null): array + { + // the file lives in the build dir, so it is already up to date if it exists + if (is_file($this->phpArrayFile)) { + return []; + } + + // without deep cloning, the array adapter keeps the objects as they are, which can then be exported + $cache = new ArrayAdapter(0, false); + + $hydrator = (clone $this->hydratorBuilder)->setCache($cache)->build(); + + // aggregates are only hydrated for snapshots, events and headers are hydrated when they are (de)serialized + $hydratedClasses = array_values($this->messageHeaderRegistry->headerClasses()); + + foreach ($this->aggregateRootRegistry->aggregateClasses() as $aggregateClass) { + if ($this->aggregateRootMetadataFactory->metadata($aggregateClass)->snapshot === null) { + continue; + } + + $hydratedClasses[] = $aggregateClass; + } + + foreach ($this->eventRegistry->eventClasses() as $eventClass) { + $hydratedClasses[] = $eventClass; + } + + foreach (array_unique($hydratedClasses) as $hydratedClass) { + $hydrator->metadata($hydratedClass); + } + + $values = array_filter($cache->getValues(), static fn (mixed $value): bool => $value !== null); + + return (new PhpArrayAdapter($this->phpArrayFile, new NullAdapter()))->warmUp($values); + } +} diff --git a/src/CacheWarmer/MetadataCacheWarmer.php b/src/CacheWarmer/MetadataCacheWarmer.php new file mode 100644 index 00000000..fafddb46 --- /dev/null +++ b/src/CacheWarmer/MetadataCacheWarmer.php @@ -0,0 +1,79 @@ + $subscriberClasses */ + public function __construct( + private readonly AggregateRootRegistry $aggregateRootRegistry, + private readonly EventRegistry $eventRegistry, + private readonly AggregateRootMetadataFactory $aggregateRootMetadataFactory, + private readonly EventMetadataFactory $eventMetadataFactory, + private readonly SubscriberMetadataFactory $subscriberMetadataFactory, + private readonly array $subscriberClasses, + private readonly string $phpArrayFile, + ) { + } + + public function isOptional(): bool + { + return true; + } + + /** @return array classes to preload */ + public function warmUp(string $cacheDir, string|null $buildDir = null): array + { + // the file lives in the build dir, so it is already up to date if it exists + if (is_file($this->phpArrayFile)) { + return []; + } + + // without deep cloning, the array adapter keeps the objects as they are, which can then be exported + $cache = new ArrayAdapter(0, false); + + $aggregateRootMetadataFactory = new Psr6AggregateRootMetadataFactory($this->aggregateRootMetadataFactory, $cache); + $eventMetadataFactory = new Psr6EventMetadataFactory($this->eventMetadataFactory, $cache); + $subscriberMetadataFactory = new Psr6SubscriberMetadataFactory($this->subscriberMetadataFactory, $cache); + + foreach ($this->aggregateRootRegistry->aggregateClasses() as $aggregateClass) { + $aggregateRootMetadataFactory->metadata($aggregateClass); + } + + foreach ($this->eventRegistry->eventClasses() as $eventClass) { + $eventMetadataFactory->metadata($eventClass); + } + + foreach ($this->subscriberClasses as $subscriberClass) { + $subscriberMetadataFactory->metadata($subscriberClass); + } + + $values = array_filter($cache->getValues(), static fn (mixed $value): bool => $value !== null); + + return (new PhpArrayAdapter($this->phpArrayFile, new NullAdapter()))->warmUp($values); + } +} diff --git a/src/DependencyInjection/MetadataCacheWarmerCompilerPass.php b/src/DependencyInjection/MetadataCacheWarmerCompilerPass.php new file mode 100644 index 00000000..a31626ff --- /dev/null +++ b/src/DependencyInjection/MetadataCacheWarmerCompilerPass.php @@ -0,0 +1,45 @@ +hasDefinition(MetadataCacheWarmer::class)) { + return; + } + + $subscriberClasses = []; + + foreach (array_keys($container->findTaggedServiceIds('event_sourcing.subscriber')) as $id) { + $class = $container->getParameterBag()->resolveValue($container->getDefinition($id)->getClass()); + + if (!is_string($class) || !class_exists($class)) { + continue; + } + + $subscriberClasses[] = $class; + } + + $container->getDefinition(MetadataCacheWarmer::class) + ->setArgument(5, array_values(array_unique($subscriberClasses))); + } +} diff --git a/src/DependencyInjection/PatchlevelEventSourcingExtension.php b/src/DependencyInjection/PatchlevelEventSourcingExtension.php index 41abd823..08249d0b 100644 --- a/src/DependencyInjection/PatchlevelEventSourcingExtension.php +++ b/src/DependencyInjection/PatchlevelEventSourcingExtension.php @@ -62,11 +62,14 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataAwareMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\Psr6AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; +use Patchlevel\EventSourcing\Metadata\Event\Psr6EventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Subscriber\Psr6SubscriberMetadataFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; use Patchlevel\EventSourcing\QueryBus\QueryBus; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; @@ -123,6 +126,8 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcingBundle\Attribute\AsListener; +use Patchlevel\EventSourcingBundle\CacheWarmer\HydratorMetadataCacheWarmer; +use Patchlevel\EventSourcingBundle\CacheWarmer\MetadataCacheWarmer; use Patchlevel\EventSourcingBundle\Clock\FrozenClockFactory; use Patchlevel\EventSourcingBundle\CommandBus\SymfonyCommandBus; use Patchlevel\EventSourcingBundle\DataCollector\EventSourcingCollector; @@ -149,10 +154,13 @@ use Patchlevel\Hydrator\StackHydrator; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\Worker\Event\WorkerRunningEvent; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Parameter; @@ -199,6 +207,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->configureValueResolver($container); $this->configureStoreMigration($config, $container); $this->configureDCB($config, $container); + $this->configureMetadataCache($container); } /** @param Config $config */ @@ -994,6 +1003,71 @@ static function (ChildDefinition $definition, Aggregate $attribute): void { $container->setAlias(RepositoryManager::class, DefaultRepositoryManager::class); } + /** + * Like in the doctrine bundle, the metadata is only cached without debug, in php files in the build dir. + * The registries are already built from the container, so only the metadata needs to be cached. + * The event sourcing metadata and the hydrator metadata of events, headers and snapshot aggregates + * each have their own file, which is created by its own cache warmer. + * Until then, the metadata is created on every request. + * With debug, nothing is cached, so that changes are picked up immediately. + */ + private function configureMetadataCache(ContainerBuilder $container): void + { + if (!$container->hasParameter('kernel.debug') || $container->getParameter('kernel.debug')) { + return; + } + + $phpArrayFile = '%kernel.build_dir%/event_sourcing/metadata.php'; + + $container->register('event_sourcing.metadata_cache', PhpArrayAdapter::class) + ->setArguments([$phpArrayFile, new Definition(ArrayAdapter::class)]); + + $pool = new Reference('event_sourcing.metadata_cache'); + + $container->register(Psr6AggregateRootMetadataFactory::class) + ->setArguments([new Reference(AggregateRootMetadataAwareMetadataFactory::class), $pool]); + $container->setAlias(AggregateRootMetadataFactory::class, Psr6AggregateRootMetadataFactory::class); + + $container->register(Psr6EventMetadataFactory::class) + ->setArguments([new Reference(AttributeEventMetadataFactory::class), $pool]); + $container->setAlias(EventMetadataFactory::class, Psr6EventMetadataFactory::class); + + $container->register(Psr6SubscriberMetadataFactory::class) + ->setArguments([new Reference(AttributeSubscriberMetadataFactory::class), $pool]); + $container->setAlias(SubscriberMetadataFactory::class, Psr6SubscriberMetadataFactory::class); + + $container->register(MetadataCacheWarmer::class) + ->setArguments([ + new Reference(AggregateRootRegistry::class), + new Reference(EventRegistry::class), + new Reference(AggregateRootMetadataAwareMetadataFactory::class), + new Reference(AttributeEventMetadataFactory::class), + new Reference(AttributeSubscriberMetadataFactory::class), + [], // the subscriber classes are set by the MetadataCacheWarmerCompilerPass + $phpArrayFile, + ]) + ->addTag('kernel.cache_warmer'); + + $hydratorPhpArrayFile = '%kernel.build_dir%/event_sourcing/hydrator_metadata.php'; + + $container->register('event_sourcing.hydrator_metadata_cache', PhpArrayAdapter::class) + ->setArguments([$hydratorPhpArrayFile, new Definition(ArrayAdapter::class)]); + + $container->getDefinition(StackHydratorBuilder::class) + ->addMethodCall('setCache', [new Reference('event_sourcing.hydrator_metadata_cache')]); + + $container->register(HydratorMetadataCacheWarmer::class) + ->setArguments([ + new Reference(StackHydratorBuilder::class), + new Reference(AggregateRootRegistry::class), + new Reference(EventRegistry::class), + new Reference(AggregateRootMetadataAwareMetadataFactory::class), + new Reference(MessageHeaderRegistry::class), + $hydratorPhpArrayFile, + ]) + ->addTag('kernel.cache_warmer'); + } + private function configureCommands(ContainerBuilder $container): void { $container->register(ShowCommand::class) diff --git a/src/PatchlevelEventSourcingBundle.php b/src/PatchlevelEventSourcingBundle.php index b4e74a89..176f8d62 100644 --- a/src/PatchlevelEventSourcingBundle.php +++ b/src/PatchlevelEventSourcingBundle.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcingBundle\DependencyInjection\DoctrineCleanupCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\HandlerServiceLocatorCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\HydratorCompilerPass; +use Patchlevel\EventSourcingBundle\DependencyInjection\MetadataCacheWarmerCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\QueryHandlerCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\RepositoryCompilerPass; use Patchlevel\EventSourcingBundle\DependencyInjection\ResourceCompilerPass; @@ -29,5 +30,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new TranslatorCompilerPass()); $container->addCompilerPass(new DoctrineCleanupCompilerPass()); $container->addCompilerPass(new HydratorCompilerPass()); + $container->addCompilerPass(new MetadataCacheWarmerCompilerPass()); } } diff --git a/tests/Unit/PatchlevelEventSourcingBundleTest.php b/tests/Unit/PatchlevelEventSourcingBundleTest.php index 9f47af52..26922297 100644 --- a/tests/Unit/PatchlevelEventSourcingBundleTest.php +++ b/tests/Unit/PatchlevelEventSourcingBundleTest.php @@ -45,15 +45,23 @@ use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\EventBus\EventBus; use Patchlevel\EventSourcing\EventBus\Psr14EventBus; +use Patchlevel\EventSourcing\Identifier\CustomId; use Patchlevel\EventSourcing\Message\Translator\ExcludeEventWithHeaderTranslator; use Patchlevel\EventSourcing\Message\Translator\RecalculatePlayheadTranslator; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootAlreadyInRegistry; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\AggregateRoot\NoAggregateRoot; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\Psr6AggregateRootMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\EventAlreadyInRegistry; +use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; +use Patchlevel\EventSourcing\Metadata\Event\Psr6EventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Message\HeaderAlreadyInRegistry; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; +use Patchlevel\EventSourcing\Metadata\Subscriber\Psr6SubscriberMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; use Patchlevel\EventSourcing\QueryBus\QueryBus; use Patchlevel\EventSourcing\Repository\DefaultRepository; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; @@ -100,6 +108,8 @@ use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\EventEmitterResolver; +use Patchlevel\EventSourcingBundle\CacheWarmer\HydratorMetadataCacheWarmer; +use Patchlevel\EventSourcingBundle\CacheWarmer\MetadataCacheWarmer; use Patchlevel\EventSourcingBundle\DependencyInjection\PatchlevelEventSourcingExtension; use Patchlevel\EventSourcingBundle\EventBus\SymfonyEventBus; use Patchlevel\EventSourcingBundle\Normalizer\SymfonyExtension; @@ -127,6 +137,9 @@ use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Metadata\MetadataFactory as HydratorMetadataFactory; +use Patchlevel\Hydrator\Metadata\Psr6MetadataFactory as Psr6HydratorMetadataFactory; use Patchlevel\Hydrator\StackHydrator; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemPoolInterface; @@ -134,6 +147,9 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -144,6 +160,9 @@ use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter; use Symfony\Component\Messenger\MessageBusInterface; +use function sys_get_temp_dir; +use function uniqid; + final class PatchlevelEventSourcingBundleTest extends TestCase { public function testEmptyConfig(): void @@ -1133,6 +1152,144 @@ public function testHeaderAliasCollidesWithInternalHeader(): void ); } + public function testMetadataIsNotCachedWithDebug(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', true); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + + self::assertFalse($container->hasDefinition(MetadataCacheWarmer::class)); + self::assertFalse($container->hasDefinition(HydratorMetadataCacheWarmer::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + } + + public function testMetadataCache(): void + { + $buildDir = sys_get_temp_dir() . '/' . uniqid('event_sourcing_build_', true); + + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.build_dir', $buildDir); + $container->setDefinition(Profile::class, new Definition(Profile::class)) + ->setAutoconfigured(true); + $container->setDefinition(ProfileCreated::class, new Definition(ProfileCreated::class)) + ->setAutoconfigured(true); + $container->setDefinition(ProfileSubscriber::class, new Definition(ProfileSubscriber::class)) + ->setAutoconfigured(true); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ], + ); + + self::assertInstanceOf( + Psr6AggregateRootMetadataFactory::class, + $container->get(AggregateRootMetadataFactory::class), + ); + self::assertInstanceOf(Psr6EventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(Psr6SubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertTrue($container->getDefinition(MetadataCacheWarmer::class)->hasTag('kernel.cache_warmer')); + self::assertTrue($container->getDefinition(HydratorMetadataCacheWarmer::class)->hasTag('kernel.cache_warmer')); + + $warmer = $container->get(MetadataCacheWarmer::class); + + self::assertInstanceOf(MetadataCacheWarmer::class, $warmer); + self::assertTrue($warmer->isOptional()); + + $warmer->warmUp($buildDir, $buildDir); + + $cache = new PhpArrayAdapter($buildDir . '/event_sourcing/metadata.php', new NullAdapter()); + + $aggregateRootMetadataFactory = $this->createMock(AggregateRootMetadataFactory::class); + $aggregateRootMetadataFactory->expects($this->never())->method('metadata'); + $eventMetadataFactory = $this->createMock(EventMetadataFactory::class); + $eventMetadataFactory->expects($this->never())->method('metadata'); + $subscriberMetadataFactory = $this->createMock(SubscriberMetadataFactory::class); + $subscriberMetadataFactory->expects($this->never())->method('metadata'); + + self::assertSame( + 'profile', + (new Psr6AggregateRootMetadataFactory($aggregateRootMetadataFactory, $cache))->metadata(Profile::class)->name, + ); + self::assertSame( + 'profile.created', + (new Psr6EventMetadataFactory($eventMetadataFactory, $cache))->metadata(ProfileCreated::class)->name, + ); + self::assertSame( + 'profile', + (new Psr6SubscriberMetadataFactory($subscriberMetadataFactory, $cache))->metadata(ProfileSubscriber::class)->id, + ); + } + + public function testHydratorMetadataCache(): void + { + $buildDir = sys_get_temp_dir() . '/' . uniqid('event_sourcing_build_', true); + $config = [ + 'patchlevel_event_sourcing' => [ + 'connection' => ['service' => 'doctrine.dbal.eventstore_connection'], + ], + ]; + + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.build_dir', $buildDir); + $this->registerHydratedClasses($container); + + $this->compileContainer($container, $config); + + $warmer = $container->get(HydratorMetadataCacheWarmer::class); + + self::assertInstanceOf(HydratorMetadataCacheWarmer::class, $warmer); + + $warmer->warmUp($buildDir, $buildDir); + + $cache = new PhpArrayAdapter($buildDir . '/event_sourcing/hydrator_metadata.php', new NullAdapter()); + + $metadataFactory = $this->createMock(HydratorMetadataFactory::class); + $metadataFactory->expects($this->once())->method('metadata')->with(Profile::class) + ->willReturn(new ClassMetadata(new ReflectionClass(Profile::class))); + + $cachedMetadataFactory = new Psr6HydratorMetadataFactory($metadataFactory, $cache); + + self::assertSame(ProfileCreated::class, $cachedMetadataFactory->metadata(ProfileCreated::class)->className); + self::assertSame(CustomHeader::class, $cachedMetadataFactory->metadata(CustomHeader::class)->className); + self::assertSame( + SnapshotableProfile::class, + $cachedMetadataFactory->metadata(SnapshotableProfile::class)->className, + ); + + // aggregates without snapshots are not hydrated, so they are not cached + $cachedMetadataFactory->metadata(Profile::class); + + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.build_dir', $buildDir); + $this->registerHydratedClasses($container); + + $this->compileContainer($container, $config); + + $hydrator = $container->get(Hydrator::class); + + self::assertInstanceOf(StackHydrator::class, $hydrator); + self::assertEquals( + new ProfileCreated(CustomId::fromString('1')), + $hydrator->hydrate(ProfileCreated::class, ['id' => '1']), + ); + self::assertSame(['id' => '1'], $hydrator->extract(new ProfileCreated(CustomId::fromString('1')))); + } + public function testRepositoryManager(): void { $container = new ContainerBuilder(); @@ -1800,6 +1957,14 @@ public function testNamedRepository(): void self::assertSame($profileRepository, $namedArgumentProfileRepository); } + private function registerHydratedClasses(ContainerBuilder $container): void + { + foreach ([Profile::class, SnapshotableProfile::class, ProfileCreated::class, CustomHeader::class] as $class) { + $container->setDefinition($class, new Definition($class)) + ->setAutoconfigured(true); + } + } + /** @param array{patchlevel_event_sourcing: array} $config */ private function compileContainer(ContainerBuilder $container, array $config): void {