diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml index fb117d213..507ce5eb6 100644 --- a/.roave-backward-compatibility-check.xml +++ b/.roave-backward-compatibility-check.xml @@ -7,5 +7,6 @@ #\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\PluginEntryPointInterface" could not be found in the located source# #\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\EventHandler\\AfterMethodCallAnalysisInterface" could not be found in the located source# #(.*)Zenstruck\\Foundry\\Utils\\Rector(.*)# + #(.*)initializeInternal(.*)# diff --git a/composer.json b/composer.json index 51901d40f..aa9e2bb58 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "symfony/browser-kit": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/maker-bundle": "^1.55", "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", @@ -84,6 +85,7 @@ }, "conflict": { "doctrine/persistence": "<2.0", + "symfony/event-dispatcher": "<6.4", "symfony/framework-bundle": "<6.4" }, "extra": { diff --git a/config/services.php b/config/services.php index acc4a128d..05e9e5849 100644 --- a/config/services.php +++ b/config/services.php @@ -46,6 +46,7 @@ service('.zenstruck_foundry.in_memory.repository_registry'), service('.foundry.persistence.objects_tracker')->nullOnInvalid(), param('zenstruck_foundry.enable_auto_refresh_with_lazy_objects'), + service('event_dispatcher')->nullOnInvalid(), ]) ->public() ; diff --git a/docs/index.rst b/docs/index.rst index a4befee73..84dc86953 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -659,6 +659,52 @@ You can also add hooks directly in your factory class: Read `Initialization`_ to learn more about the ``initialize()`` method. +Events +~~~~~~ + +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: + +:: + + use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + use Zenstruck\Foundry\Object\Event\AfterInstantiate; + use Zenstruck\Foundry\Object\Event\BeforeInstantiate; + use Zenstruck\Foundry\Persistence\Event\AfterPersist; + + final class FoundryEventListener + { + #[AsEventListener] + public function beforeInstantiate(BeforeInstantiate $event): void + { + // do something before the object 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] + public function afterInstantiate(AfterInstantiate $event): void + { + // $event->object is the instantiated 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] + public function afterPersist(AfterPersist $event): void + { + // this event is only called if the object was persisted + // $event->object is the persisted 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 + } + } + +.. versionadded:: 2.4 + + Those events are triggered since Foundry 2.4. + Initialization ~~~~~~~~~~~~~~ diff --git a/src/Configuration.php b/src/Configuration.php index 818941d30..298826941 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -12,6 +12,7 @@ namespace Zenstruck\Foundry; use Faker; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Zenstruck\Foundry\Exception\FactoriesTraitNotUsed; use Zenstruck\Foundry\Exception\FoundryNotBooted; use Zenstruck\Foundry\Exception\PersistenceDisabled; @@ -63,6 +64,7 @@ public function __construct( public readonly ?InMemoryRepositoryRegistry $inMemoryRepositoryRegistry = null, public readonly ?PersistedObjectsTracker $persistedObjectsTracker = null, private readonly bool $enableAutoRefreshWithLazyObjects = false, + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { if (null === self::$instance) { $this->faker->seed(self::fakerSeed($forcedFakerSeed)); @@ -106,6 +108,16 @@ public function assertPersistenceEnabled(): void } } + public function hasEventDispatcher(): bool + { + return (bool) $this->eventDispatcher; + } + + public function eventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher ?? throw new \RuntimeException('No event dispatcher configured.'); + } + public function inADataProvider(): bool { return $this->bootedForDataProvider; diff --git a/src/Object/Event/AfterInstantiate.php b/src/Object/Event/AfterInstantiate.php new file mode 100644 index 000000000..7bbef74f5 --- /dev/null +++ b/src/Object/Event/AfterInstantiate.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Object\Event; + +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; + +/** + * @author Nicolas PHILIPPE + * + * @phpstan-import-type Parameters from Factory + */ +final class AfterInstantiate +{ + public function __construct( + public readonly object $object, + /** @phpstan-var Parameters */ + public readonly array $parameters, + /** @var ObjectFactory */ + public readonly ObjectFactory $factory, + ) { + } +} diff --git a/src/Object/Event/BeforeInstantiate.php b/src/Object/Event/BeforeInstantiate.php new file mode 100644 index 000000000..b5ad5cb38 --- /dev/null +++ b/src/Object/Event/BeforeInstantiate.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Object\Event; + +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; + +/** + * @author Nicolas PHILIPPE + * + * @phpstan-import-type Parameters from Factory + */ +final class BeforeInstantiate +{ + public function __construct( + /** @phpstan-var Parameters */ + public array $parameters, + /** @var class-string */ + public readonly string $objectClass, + /** @var ObjectFactory */ + public readonly ObjectFactory $factory, + ) { + } +} diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 70e4a12ac..c61875f9c 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -11,6 +11,8 @@ namespace Zenstruck\Foundry; +use Zenstruck\Foundry\Object\Event\AfterInstantiate; +use Zenstruck\Foundry\Object\Event\BeforeInstantiate; use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\Persistence\ProxyGenerator; @@ -191,6 +193,33 @@ final protected function normalizeReusedAttributes(): array return $attributes; } + /** + * @internal + */ + protected function initializeInternal(): static + { + if (!Configuration::isBooted() || !Configuration::instance()->hasEventDispatcher()) { + return $this; + } + + return $this->beforeInstantiate( + static function(array $parameters, string $objectClass, self $usedFactory): array { + Configuration::instance()->eventDispatcher()->dispatch( + $hook = new BeforeInstantiate($parameters, $objectClass, $usedFactory) + ); + + return $hook->parameters; + } + ) + ->afterInstantiate( + static function(object $object, array $parameters, self $usedFactory): void { + Configuration::instance()->eventDispatcher()->dispatch( + new AfterInstantiate($object, $parameters, $usedFactory) + ); + } + ); + } + /** * @return list * @internal diff --git a/src/Persistence/Event/AfterPersist.php b/src/Persistence/Event/AfterPersist.php new file mode 100644 index 000000000..eb89763c2 --- /dev/null +++ b/src/Persistence/Event/AfterPersist.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Persistence\Event; + +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; + +/** + * @author Nicolas PHILIPPE + * + * @phpstan-import-type Parameters from Factory + */ +final class AfterPersist +{ + public function __construct( + public readonly object $object, + /** @phpstan-var Parameters */ + public readonly array $parameters, + /** @var PersistentObjectFactory */ + public readonly PersistentObjectFactory $factory, + ) { + } +} diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index f70a5a316..4fb4bbf29 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -35,7 +35,7 @@ final class PersistenceManager private bool $flush = true; private bool $persist = true; - /** @var list */ + /** @var list */ private array $afterPersistCallbacks = []; /** @@ -79,9 +79,9 @@ public function save(object $object): object $om->persist($object); $this->flush($om); - $callbacksCalled = $this->callPostPersistCallbacks(); + $shouldFlush = $this->callPostPersistCallbacks(); - if ($callbacksCalled) { + if ($shouldFlush) { $this->flush($om); } @@ -96,7 +96,7 @@ public function save(object $object): object * @template T of object * * @param T $object - * @param list $afterPersistCallbacks + * @param list $afterPersistCallbacks * * @return T */ @@ -445,11 +445,15 @@ private function callPostPersistCallbacks(): bool $afterPersistCallbacks = $this->afterPersistCallbacks; $this->afterPersistCallbacks = []; + $shouldFlush = false; + foreach ($afterPersistCallbacks as $afterPersistCallback) { - $afterPersistCallback(); + if ($afterPersistCallback()) { + $shouldFlush = true; + } } - return true; + return $shouldFlush; } /** diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 9733b2545..b7a48e1d1 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -21,6 +21,7 @@ use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Object\Hydrator; use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Persistence\Exception\NotEnoughObjects; use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; use Zenstruck\Foundry\Persistence\Relationship\ManyToOneRelationship; @@ -43,7 +44,7 @@ abstract class PersistentObjectFactory extends ObjectFactory { private PersistMode $persist = PersistMode::PERSIST; - /** @phpstan-var list */ + /** @phpstan-var list */ private array $afterPersist = []; /** @var list */ @@ -320,7 +321,7 @@ public function withPersistMode(PersistMode $persistMode): static } /** - * @phpstan-param callable(T, Parameters, static):void $callback + * @phpstan-param callable(T, Parameters, static):void|callable(T, Parameters, static):bool $callback return value tells if a flush should be performed after the callback */ final public function afterPersist(callable $callback): static { @@ -507,10 +508,13 @@ protected function normalizeObject(string $field, object $object): object } } + /** + * @internal + */ final protected function initializeInternal(): static { // Schedule any new object for insert right after instantiation - return parent::initializeInternal() + $factory = parent::initializeInternal() ->afterInstantiate( static function(object $object, array $parameters, PersistentObjectFactory $factoryUsed): void { if (!$factoryUsed->isPersisting()) { @@ -527,8 +531,9 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact $afterPersistCallbacks = []; foreach ($factoryUsed->afterPersist as $afterPersist) { - $afterPersistCallbacks[] = static function() use ($object, $afterPersist, $parameters, $factoryUsed): void { - $afterPersist($object, $parameters, $factoryUsed); + $afterPersistCallbacks[] = static function() use ($object, $afterPersist, $parameters, $factoryUsed): bool { + // this condition is needed to avoid BC breaks: only avoir flush if the callback returns false + return !($afterPersist($object, $parameters, $factoryUsed) === false); }; } @@ -536,6 +541,20 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact } ) ; + + if (!Configuration::isBooted() || !Configuration::instance()->hasEventDispatcher()) { + return $factory; + } + + return $factory->afterPersist( + static function(object $object, array $parameters, self $factoryUsed): bool { + Configuration::instance()->eventDispatcher()->dispatch( + new AfterPersist($object, $parameters, $factoryUsed) + ); + + return false; // don't perform a flush after the hook + } + ); } private function isAutorefreshEnabled(): bool diff --git a/tests/Benchmark/KernelBench.php b/tests/Benchmark/KernelBench.php index 5a696a41f..56dcad595 100644 --- a/tests/Benchmark/KernelBench.php +++ b/tests/Benchmark/KernelBench.php @@ -148,7 +148,7 @@ protected static function createKernel(array $options = []): KernelInterface $env = $options['environment'] ?? $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test'; $debug = $options['debug'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true; - return new static::$class($env, $debug); + return new static::$class($env, false); } /** diff --git a/tests/Fixture/Entity/EntityForEventListeners.php b/tests/Fixture/Entity/EntityForEventListeners.php new file mode 100644 index 000000000..4d36182b5 --- /dev/null +++ b/tests/Fixture/Entity/EntityForEventListeners.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +#[ORM\Entity] +class EntityForEventListeners extends Base +{ + public function __construct( + #[ORM\Column()] + public string $name, + ) { + } +} diff --git a/tests/Fixture/Events/FactoryWithEventListeners.php b/tests/Fixture/Events/FactoryWithEventListeners.php new file mode 100644 index 000000000..dcda63c7b --- /dev/null +++ b/tests/Fixture/Events/FactoryWithEventListeners.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Events; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\EntityForEventListeners; + +/** + * @extends PersistentObjectFactory + */ +final class FactoryWithEventListeners extends PersistentObjectFactory +{ + public static function class(): string + { + return EntityForEventListeners::class; + } + + /** + * @return array + */ + protected function defaults(): array + { + return [ + 'name' => self::faker()->sentence(), + ]; + } +} diff --git a/tests/Fixture/Events/FoundryEventListener.php b/tests/Fixture/Events/FoundryEventListener.php new file mode 100644 index 000000000..ddc00e4f0 --- /dev/null +++ b/tests/Fixture/Events/FoundryEventListener.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Events; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Zenstruck\Foundry\Object\Event\AfterInstantiate; +use Zenstruck\Foundry\Object\Event\BeforeInstantiate; +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Tests\Fixture\Entity\EntityForEventListeners; + +final class FoundryEventListener +{ + #[AsEventListener] + public function beforeInstantiate(BeforeInstantiate $event): void + { + if (EntityForEventListeners::class !== $event->objectClass) { + return; + } + + $event->parameters['name'] = "{$event->parameters['name']}\nBeforeInstantiate"; + } + + #[AsEventListener] + public function afterInstantiate(AfterInstantiate $event): void + { + if (!$event->object instanceof EntityForEventListeners) { + return; + } + + $event->object->name = "{$event->object->name}\nAfterInstantiate"; + } + + #[AsEventListener] + public function afterPersist(AfterPersist $event): void + { + if (!$event->object instanceof EntityForEventListeners) { + return; + } + + $event->object->name = "{$event->object->name}\nAfterPersist"; + } +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index fe5b8ee6b..493e41d6c 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -20,6 +20,7 @@ use Zenstruck\Foundry\Tests\Fixture\App\Controller\CreateContact; use Zenstruck\Foundry\Tests\Fixture\App\Controller\DeleteGenericModel; use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel; +use Zenstruck\Foundry\Tests\Fixture\Events\FoundryEventListener; use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory; use Zenstruck\Foundry\Tests\Fixture\Factories\Object1Factory; use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryAddressRepository; @@ -62,6 +63,8 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(InMemoryAddressRepository::class)->setAutowired(true)->setAutoconfigured(true); $c->register(InMemoryContactRepository::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(FoundryEventListener::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(DeleteGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(CreateContact::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); diff --git a/tests/Integration/ORM/GenericEntityFactoryTest.php b/tests/Integration/ORM/GenericEntityFactoryTest.php index 170b3cd85..23224f8b4 100644 --- a/tests/Integration/ORM/GenericEntityFactoryTest.php +++ b/tests/Integration/ORM/GenericEntityFactoryTest.php @@ -11,9 +11,11 @@ namespace Zenstruck\Foundry\Tests\Integration\ORM; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\EmptyConstructorFactory; use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; +use Zenstruck\Foundry\Tests\Fixture\Model\GenericModel; use Zenstruck\Foundry\Tests\Integration\Persistence\GenericFactoryTestCase; use Zenstruck\Foundry\Tests\Integration\RequiresORM; @@ -55,6 +57,60 @@ public function can_use_factory_with_empty_constructor_without_persistence(): vo EmptyConstructorFactory::assert()->count(0); } + /** + * @test + * @dataProvider afterPersistDecideFlushProvider + * + * @phpstan-ignore missingType.callable + */ + #[Test] + #[DataProvider('afterPersistDecideFlushProvider')] + public function after_persist_callback_can_decide_if_flush_is_performed_afterwards(callable $callback, string $expected): void + { + static::factory() + ->afterPersist($callback) + ->create(['prop1' => 'foo']); + + static::factory()::assert()->exists(['prop1' => $expected]); + } + + public static function afterPersistDecideFlushProvider(): iterable + { + yield 'no return will flush' => [ + function(GenericModel $object) { + $object->setProp1('bar'); + }, + 'bar' + ]; + + yield 'return true will flush' => [ + function(GenericModel $object) { + $object->setProp1('bar'); + + return true; + }, + 'bar' + ]; + + yield 'return something else than false will flush' => [ + function(GenericModel $object) { + $object->setProp1('bar'); + + return $object; + }, + 'bar' + ]; + + yield 'return false will not flush' => [ + function(GenericModel $object) { + $object->setProp1('bar'); + + return false; + }, + 'foo' + ]; + } + protected static function factory(): GenericEntityFactory { return GenericEntityFactory::new(); diff --git a/tests/Integration/Persistence/EventsTest.php b/tests/Integration/Persistence/EventsTest.php new file mode 100644 index 000000000..4989ebe5e --- /dev/null +++ b/tests/Integration/Persistence/EventsTest.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\Tests\Integration\Persistence; + +use PHPUnit\Framework\Attributes\Test; +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 +{ + use Factories, RequiresORM, ResetDatabase; + + /** + * @test + */ + #[Test] + public function it_can_call_hooks(): void + { + $address = FactoryWithEventListeners::createOne(['name' => 'events']); + + self::assertSame( + <<name + ); + } +}