diff --git a/docs/index.rst b/docs/index.rst index 7921a68c4..f064f1294 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -603,11 +603,11 @@ You can also add hooks directly in your factory class: Read `Initialization`_ to learn more about the ``initialize()`` method. -Events -~~~~~~ +Hooks as service / global hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In addition to hooks, Foundry also leverages `symfony/event-dispatcher` and dispatches events that you can listen to, -allowing to create hooks globally, as Symfony services: +For a better control of your hooks, you can define them as services, allowing to leverage dependency injection and +to create hooks globally: :: @@ -616,26 +616,26 @@ allowing to create hooks globally, as Symfony services: use Zenstruck\Foundry\Object\Event\BeforeInstantiate; use Zenstruck\Foundry\Persistence\Event\AfterPersist; - final class FoundryEventListener + final class FoundryHook { - #[AsEventListener] + #[AsFoundryHook(Post::class)] public function beforeInstantiate(BeforeInstantiate $event): void { - // do something before the object is instantiated: + // do something before the post is instantiated: // $event->parameters is what will be used to instantiate the object, manipulate as required // $event->objectClass is the class of the object being instantiated // $event->factory is the factory instance which creates the object } - #[AsEventListener] + #[AsFoundryHook(Post::class)] public function afterInstantiate(AfterInstantiate $event): void { - // $event->object is the instantiated object + // $event->object is the instantiated Post object // $event->parameters contains the attributes used to instantiate the object and any extras // $event->factory is the factory instance which creates the object } - #[AsEventListener] + #[AsFoundryHook(Post::class)] public function afterPersist(AfterPersist $event): void { // this event is only called if the object was persisted @@ -643,11 +643,17 @@ allowing to create hooks globally, as Symfony services: // $event->parameters contains the attributes used to instantiate the object and any extras // $event->factory is the factory instance which creates the object } + + #[AsFoundryHook] + public function afterInstantiateGlobal(AfterInstantiate $event): void + { + // Omitting class defines a "global" hook which will be called for all objects + } } .. versionadded:: 2.4 - Those events are triggered since Foundry 2.4. + The ``#[AsFoundryHook]`` attribute was added in Foundry 2.4. Initialization ~~~~~~~~~~~~~~ diff --git a/src/Attribute/AsFoundryHook.php b/src/Attribute/AsFoundryHook.php new file mode 100644 index 000000000..68ff14e16 --- /dev/null +++ b/src/Attribute/AsFoundryHook.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[\Attribute(\Attribute::TARGET_METHOD)] +final class AsFoundryHook extends AsEventListener +{ + public function __construct( + /** @var class-string */ + public readonly ?string $objectClass = null, + int $priority = 0, + ) { + parent::__construct(priority: $priority); + } +} diff --git a/src/Object/Event/AfterInstantiate.php b/src/Object/Event/AfterInstantiate.php index 7bbef74f5..356256531 100644 --- a/src/Object/Event/AfterInstantiate.php +++ b/src/Object/Event/AfterInstantiate.php @@ -19,16 +19,25 @@ /** * @author Nicolas PHILIPPE * + * @template T of object + * @implements Event + * * @phpstan-import-type Parameters from Factory */ -final class AfterInstantiate +final class AfterInstantiate implements Event { public function __construct( + /** @var T */ public readonly object $object, /** @phpstan-var Parameters */ public readonly array $parameters, - /** @var ObjectFactory */ + /** @var ObjectFactory */ public readonly ObjectFactory $factory, ) { } + + public function objectClassName(): string + { + return $this->object::class; + } } diff --git a/src/Object/Event/BeforeInstantiate.php b/src/Object/Event/BeforeInstantiate.php index b5ad5cb38..a79174056 100644 --- a/src/Object/Event/BeforeInstantiate.php +++ b/src/Object/Event/BeforeInstantiate.php @@ -19,17 +19,25 @@ /** * @author Nicolas PHILIPPE * + * @template T of object + * @implements Event + * * @phpstan-import-type Parameters from Factory */ -final class BeforeInstantiate +final class BeforeInstantiate implements Event { public function __construct( /** @phpstan-var Parameters */ public array $parameters, - /** @var class-string */ + /** @var class-string */ public readonly string $objectClass, - /** @var ObjectFactory */ + /** @var ObjectFactory */ public readonly ObjectFactory $factory, ) { } + + public function objectClassName(): string + { + return $this->objectClass; + } } diff --git a/src/Object/Event/Event.php b/src/Object/Event/Event.php new file mode 100644 index 000000000..95382cb25 --- /dev/null +++ b/src/Object/Event/Event.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Object\Event; + +/** + * @template T of object + */ +interface Event +{ + /** + * @return class-string + */ + public function objectClassName(): string; +} diff --git a/src/Object/Event/HookListenerFilter.php b/src/Object/Event/HookListenerFilter.php new file mode 100644 index 000000000..12e28e0eb --- /dev/null +++ b/src/Object/Event/HookListenerFilter.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Object\Event; + +final class HookListenerFilter +{ + /** @var \Closure(Event): void */ + private \Closure $listener; + + /** + * @param array{0: object, 1: string} $listener + * @param class-string|null $objectClass + */ + public function __construct(array $listener, private ?string $objectClass = null) + { + if (!\is_callable($listener)) { + throw new \InvalidArgumentException(\sprintf('Listener must be a callable, "%s" given.', \get_debug_type($listener))); + } + + $this->listener = $listener(...); + } + + /** + * @param Event $event + */ + public function __invoke(Event $event): void + { + if ($this->objectClass && $event->objectClassName() !== $this->objectClass) { + return; + } + + ($this->listener)($event); + } +} diff --git a/src/Persistence/Event/AfterPersist.php b/src/Persistence/Event/AfterPersist.php index eb89763c2..6ab3fa293 100644 --- a/src/Persistence/Event/AfterPersist.php +++ b/src/Persistence/Event/AfterPersist.php @@ -14,21 +14,31 @@ namespace Zenstruck\Foundry\Persistence\Event; use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\Object\Event\Event; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** * @author Nicolas PHILIPPE * + * @template T of object + * @implements Event + * * @phpstan-import-type Parameters from Factory */ -final class AfterPersist +final class AfterPersist implements Event { public function __construct( + /** @var T */ public readonly object $object, /** @phpstan-var Parameters */ public readonly array $parameters, - /** @var PersistentObjectFactory */ + /** @var PersistentObjectFactory */ public readonly PersistentObjectFactory $factory, ) { } + + public function objectClassName(): string + { + return $this->object::class; + } } diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 52a55e91f..3036897c4 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -12,12 +12,18 @@ namespace Zenstruck\Foundry; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Zenstruck\Foundry\Attribute\AsFoundryHook; use Zenstruck\Foundry\Mongo\MongoResetter; +use Zenstruck\Foundry\Object\Event\Event; +use Zenstruck\Foundry\Object\Event\HookListenerFilter; use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\ORM\ResetDatabase\MigrateDatabaseResetter; use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; @@ -282,6 +288,25 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->replaceArgument(0, $config['mongo']['reset']['document_managers']) ; } + + $container->registerAttributeForAutoconfiguration( + AsFoundryHook::class, + // @phpstan-ignore argument.type + static function(ChildDefinition $definition, AsFoundryHook $attribute, \ReflectionMethod $reflector) { + if (1 !== \count($reflector->getParameters()) + || !$reflector->getParameters()[0]->getType() + || !$reflector->getParameters()[0]->getType() instanceof \ReflectionNamedType + || !\is_a($reflector->getParameters()[0]->getType()->getName(), Event::class, true) + ) { + throw new LogicException(\sprintf("In order to use \"%s\" attribute, method \"{$reflector->class}::{$reflector->name}()\" must have a single parameter that is a subclass of \"%s\".", AsFoundryHook::class, Event::class)); + } + $definition->addTag('foundry.hook', [ + 'class' => $attribute->objectClass, + 'method' => $reflector->getName(), + 'event' => $reflector->getParameters()[0]->getType()->getName(), + ]); + } + ); } public function build(ContainerBuilder $container): void @@ -300,6 +325,21 @@ public function process(ContainerBuilder $container): void ->addMethodCall('addProvider', [new Reference($id)]) ; } + + // events + $i = 0; + foreach ($container->findTaggedServiceIds('foundry.hook') as $id => $tags) { + foreach ($tags as $tag) { + $container + ->setDefinition("foundry.hook.{$tag['event']}.{$i}", new Definition(class: HookListenerFilter::class)) + ->setArgument(0, [new Reference($id), $tag['method']]) + ->setArgument(1, $tag['class']) + ->addTag('kernel.event_listener', ['event' => $tag['event']]) + ; + + ++$i; + } + } } /** diff --git a/tests/Fixture/Events/FoundryEventListener.php b/tests/Fixture/Events/FoundryEventListener.php index ddc00e4f0..c9df24006 100644 --- a/tests/Fixture/Events/FoundryEventListener.php +++ b/tests/Fixture/Events/FoundryEventListener.php @@ -14,13 +14,16 @@ namespace Zenstruck\Foundry\Tests\Fixture\Events; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Zenstruck\Foundry\Attribute\AsFoundryHook; use Zenstruck\Foundry\Object\Event\AfterInstantiate; use Zenstruck\Foundry\Object\Event\BeforeInstantiate; +use Zenstruck\Foundry\Object\Event\Event; use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Tests\Fixture\Entity\EntityForEventListeners; final class FoundryEventListener { + /** @param BeforeInstantiate $event */ #[AsEventListener] public function beforeInstantiate(BeforeInstantiate $event): void { @@ -28,9 +31,10 @@ public function beforeInstantiate(BeforeInstantiate $event): void return; } - $event->parameters['name'] = "{$event->parameters['name']}\nBeforeInstantiate"; + $event->parameters['name'] = $this->name($event->parameters['name'], $event); } + /** @param AfterInstantiate $event */ #[AsEventListener] public function afterInstantiate(AfterInstantiate $event): void { @@ -38,9 +42,10 @@ public function afterInstantiate(AfterInstantiate $event): void return; } - $event->object->name = "{$event->object->name}\nAfterInstantiate"; + $event->object->name = $this->name($event->object->name, $event); } + /** @param AfterPersist $event */ #[AsEventListener] public function afterPersist(AfterPersist $event): void { @@ -48,6 +53,67 @@ public function afterPersist(AfterPersist $event): void return; } - $event->object->name = "{$event->object->name}\nAfterPersist"; + $event->object->name = $this->name($event->object->name, $event); + } + + /** @param BeforeInstantiate $event */ + #[AsFoundryHook(EntityForEventListeners::class)] + public function beforeInstantiateWithFoundryAttribute(BeforeInstantiate $event): void + { + $event->parameters['name'] = "{$this->name($event->parameters['name'], $event)} with Foundry attribute"; + } + + /** @param AfterInstantiate $event */ + #[AsFoundryHook(EntityForEventListeners::class)] + public function afterInstantiateWithFoundryAttribute(AfterInstantiate $event): void + { + $event->object->name = "{$this->name($event->object->name, $event)} with Foundry attribute"; + } + + /** @param AfterPersist $event */ + #[AsFoundryHook(EntityForEventListeners::class)] + public function afterPersistWithFoundryAttribute(AfterPersist $event): void + { + $event->object->name = "{$this->name($event->object->name, $event)} with Foundry attribute"; + } + + /** @param BeforeInstantiate $event */ + #[AsFoundryHook()] + public function globalBeforeInstantiate(BeforeInstantiate $event): void + { + if (EntityForEventListeners::class !== $event->objectClass) { + return; + } + + $event->parameters['name'] = "{$this->name($event->parameters['name'], $event)} global"; + } + + /** @param AfterInstantiate $event */ + #[AsFoundryHook()] + public function globalAfterInstantiate(AfterInstantiate $event): void + { + if (!$event->object instanceof EntityForEventListeners) { + return; + } + + $event->object->name = "{$this->name($event->object->name, $event)} global"; + } + + /** @param AfterPersist $event */ + #[AsFoundryHook()] + public function globalAfterPersist(AfterPersist $event): void + { + if (!$event->object instanceof EntityForEventListeners) { + return; + } + + $event->object->name = "{$this->name($event->object->name, $event)} global"; + } + + private function name(string $name, Event $event): string // @phpstan-ignore missingType.generics + { + $eventName = (new \ReflectionClass($event))->getShortName(); + + return "{$name}\n{$eventName}"; } } diff --git a/tests/Integration/Persistence/EventsTest.php b/tests/Integration/EventsTest.php similarity index 74% rename from tests/Integration/Persistence/EventsTest.php rename to tests/Integration/EventsTest.php index 358d0f3bc..25398d187 100644 --- a/tests/Integration/Persistence/EventsTest.php +++ b/tests/Integration/EventsTest.php @@ -11,13 +11,12 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Integration\Persistence; +namespace Zenstruck\Foundry\Tests\Integration; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; use Zenstruck\Foundry\Tests\Fixture\Events\FactoryWithEventListeners; -use Zenstruck\Foundry\Tests\Integration\RequiresORM; final class EventsTest extends KernelTestCase { @@ -34,8 +33,14 @@ public function it_can_call_hooks(): void <<name );