Skip to content

Commit

Permalink
feat: introduce attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
nikophil committed Feb 3, 2025
1 parent 409a367 commit 10cdddb
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 20 deletions.
22 changes: 14 additions & 8 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

::

Expand All @@ -618,7 +618,7 @@ allowing to create hooks globally, as Symfony services:

final class FoundryEventListener
{
#[AsEventListener]
#[AsFoundryHook(Post::class)]
public function beforeInstantiate(BeforeInstantiate $event): void
{
// do something before the object is instantiated:
Expand All @@ -627,27 +627,33 @@ allowing to create hooks globally, as Symfony services:
// $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->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
// $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
}

#[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
~~~~~~~~~~~~~~
Expand Down
28 changes: 28 additions & 0 deletions src/Attribute/AsFoundryHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* 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);
}
}
13 changes: 11 additions & 2 deletions src/Object/Event/AfterInstantiate.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,25 @@
/**
* @author Nicolas PHILIPPE <[email protected]>
*
* @template T of object
* @implements Event<T>
*
* @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<object> */
/** @var ObjectFactory<T> */
public readonly ObjectFactory $factory,
) {
}

public function objectClassName(): string
{
return $this->object::class;
}
}
14 changes: 11 additions & 3 deletions src/Object/Event/BeforeInstantiate.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,25 @@
/**
* @author Nicolas PHILIPPE <[email protected]>
*
* @template T of object
* @implements Event<T>
*
* @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<T> */
public readonly string $objectClass,
/** @var ObjectFactory<object> */
/** @var ObjectFactory<T> */
public readonly ObjectFactory $factory,
) {
}

public function objectClassName(): string
{
return $this->objectClass;
}
}
25 changes: 25 additions & 0 deletions src/Object/Event/Event.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* 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<T>
*/
public function objectClassName(): string;
}
45 changes: 45 additions & 0 deletions src/Object/Event/HookListenerFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* 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<object>): 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<object> $event
*/
public function __invoke(Event $event): void
{
if ($this->objectClass && $event->objectClassName() !== $this->objectClass) {
return;
}

($this->listener)($event);
}
}
14 changes: 12 additions & 2 deletions src/Persistence/Event/AfterPersist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
*
* @template T of object
* @implements Event<T>
*
* @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<object> */
/** @var PersistentObjectFactory<T> */
public readonly PersistentObjectFactory $factory,
) {
}

public function objectClassName(): string
{
return $this->object::class;
}
}
40 changes: 40 additions & 0 deletions src/ZenstruckFoundryBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
}

/**
Expand Down
Loading

0 comments on commit 10cdddb

Please sign in to comment.