From b90e57bea3e5b96002bbffb339fff9ed0ba98113 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 25 May 2021 21:40:15 -0700 Subject: [PATCH] EventScanner - Add utility class which can auto-identify listeners in an object ``` $map = EventScanner::findListeners($someObject); $dispatcher->addListenerMap($someObject, $map); ``` --- Civi/Core/CiviEventDispatcher.php | 47 +++++++ Civi/Core/Event/EventPrinter.php | 50 ++++++++ Civi/Core/Event/EventScanner.php | 176 ++++++++++++++++++++++++++ Civi/Core/Event/HookStyleListener.php | 41 ++++++ Civi/Core/Event/ServiceListener.php | 4 +- 5 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 Civi/Core/Event/EventPrinter.php create mode 100644 Civi/Core/Event/EventScanner.php create mode 100644 Civi/Core/Event/HookStyleListener.php diff --git a/Civi/Core/CiviEventDispatcher.php b/Civi/Core/CiviEventDispatcher.php index dd3bc3f7e5..f2ddb6431e 100644 --- a/Civi/Core/CiviEventDispatcher.php +++ b/Civi/Core/CiviEventDispatcher.php @@ -2,6 +2,7 @@ namespace Civi\Core; +use Civi\Core\Event\HookStyleListener; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\Event; @@ -89,6 +90,52 @@ class CiviEventDispatcher extends EventDispatcher { } } + /** + * Add a test listener. + * + * @param string $eventName + * Ex: 'civi.internal.event' + * Ex: 'hook_civicrm_publicEvent' + * Ex: '&hook_civicrm_publicEvent' (an alias for 'hook_civicrm_publicEvent' in which the listener abides hook-style ordered parameters). + * This notation is handy when attaching via listener-maps (e.g. `getSubscribedEvents()`). + * @param callable $listener + * @param int $priority + */ + public function addListener($eventName, $listener, $priority = 0) { + if ($eventName[0] === '&') { + $eventName = substr($eventName, 1); + $listener = new HookStyleListener($listener); + } + parent::addListener($eventName, $listener, $priority); + } + + /** + * Adds a series of event listeners from methods in a class. + * + * @param string|object $target + * The object/class which will receive the notifications. + * Use a string (class-name) if the listeners are static methods. + * Use an object-instance if the listeners are regular methods. + * @param array $events + * List of events/methods/priorities. + * @see \Symfony\Component\EventDispatcher\EventSubscriberInterface::getSubscribedEvents() + */ + public function addListenerMap($target, array $events) { + foreach ($events as $eventName => $params) { + if (\is_string($params)) { + $this->addListener($eventName, [$target, $params]); + } + elseif (\is_string($params[0])) { + $this->addListener($eventName, [$target, $params[0]], isset($params[1]) ? $params[1] : 0); + } + else { + foreach ($params as $listener) { + $this->addListener($eventName, [$target, $listener[0]], isset($listener[1]) ? $listener[1] : 0); + } + } + } + } + /** * Adds a service as event listener. * diff --git a/Civi/Core/Event/EventPrinter.php b/Civi/Core/Event/EventPrinter.php new file mode 100644 index 0000000000..6c70e1fa1b --- /dev/null +++ b/Civi/Core/Event/EventPrinter.php @@ -0,0 +1,50 @@ +$b(\$e)"; + } + elseif (is_string($a)) { + return $normalizeNamespace($a) . "::$b(\$e)"; + } + } + elseif (is_string($callback)) { + return $normalizeNamespace($callback) . '(\$e)'; + } + elseif ($callback instanceof ServiceListener || $callback instanceof HookStyleListener) { + return (string) $callback; + } + else { + try { + $f = new \ReflectionFunction($callback); + return 'closure<' . $f->getFileName() . '@' . $f->getStartLine() . '>($e)'; + } + catch (\ReflectionException $e) { + } + } + + return 'unidentified'; + } + +} diff --git a/Civi/Core/Event/EventScanner.php b/Civi/Core/Event/EventScanner.php new file mode 100644 index 0000000000..ff9526ec5f --- /dev/null +++ b/Civi/Core/Event/EventScanner.php @@ -0,0 +1,176 @@ +addListenerMap($someObject, $map); + * ``` + */ +class EventScanner { + + /** + * In-memory cache of the listener-maps found on various classes. + * + * This cache is a little unusual -- it is geared toward improving unit-tests. Bear in mind: + * + * - The `EventScanner` is fundamentally scanning class-structure. + * - Within a given PHP process, the class-structure cannot change. Therefore, the cached view in `static::$listenerMaps` cannot be stale. + * - There are three kinds of PHP processes: + * 1. System-flushes -- During this operation, we rebuild the `Container`. This may do some scanning, and the results will be recorded in `Container`. + * 2. Ordinary page-loads -- We use the `Container` cache. It shouldn't need any more scans. + * 3. Headless unit-tests -- For these, we must frequently tear-down and rebuild a fresh `Container`, often with varying decisions about + * which extensions/services/classes to activate. The container-cache does not operate. + * + * Here's how `$listenerMaps` plays out in each: + * + * 1. The `$listenerMaps` is not needed or used. + * 2. The `$listenerMaps` (and `EventScanner` generally) is not needed or used. + * 3. The `$listenerMaps` is used frequently, preventing redundant scanning. + * + * A more common approach would be to use `Civi::$statics` or `Civi::cache()`. These would be inappropriate because we want the data to be + * preserved across multiple test-runs -- and because the underlying data (PHP class-structure) does not change within a unit-test. + * + * @var array + * Ex: ['api_v3_SyntaxConformanceTest' => [...listener-names...]] + */ + private static $listenerMaps = []; + + /** + * Scan an object or class for event listeners. + * + * Note: This requires scanning. Consequently, it should not be run in bulk on a regular (runtime) basis. Instead, store + * the listener-maps in a cache (e.g. `Container`). + * + * @param string|object $target + * The object/class which will receive the notifications. + * Use a string (class-name) if the listeners are static methods. + * Use an object-instance if the listeners are regular methods. + * @param string|null $self + * If the target $class is focused on a specific entity/form/etc, use the `$self` parameter to specify it. + * This will activate support for `self_{$event}` methods. + * Ex: if '$self' is 'Contact', then 'function self_hook_civicrm_pre()' maps to 'hook_civicrm_pre::Contact'. + * @return array + * List of events/listeners. Format is compatible with 'getSubscribedEvents()'. + * Ex: ['some.event' => [['firstFunc'], ['secondFunc']] + */ + public static function findListeners($target, $self = NULL) { + $class = is_object($target) ? get_class($target) : $target; + $key = "$class::" . ($self ?: ''); + if (isset(self::$listenerMaps[$key])) { + return self::$listenerMaps[$key]; + } + + $listenerMap = []; + // FIXME: Inteface misnomer + if (is_subclass_of($class, '\Civi\Test\HookInterface')) { + $listenerMap = static::mergeListenerMap($listenerMap, static::findFunctionListeners($class, $self)); + } + if (is_subclass_of($class, '\Symfony\Component\EventDispatcher\EventSubscriberInterface')) { + $listenerMap = static::mergeListenerMap($listenerMap, static::normalizeListenerMap($class::getSubscribedEvents())); + } + + if (CIVICRM_UF === 'UnitTests') { + self::$listenerMaps[$key] = $listenerMap; + } + return $listenerMap; + } + + /** + * @param string $class + * @param string|null $self + * If the target $class is focused on a specific entity/form/etc, use the `$self` parameter to specify it. + * This will activate support for `self_{$event}` methods. + * Ex: if '$self' is 'Contact', then 'function self_hook_civicrm_pre()' maps to 'hook_civicrm_pre::Contact'. + * @return \Generator + */ + protected static function findFunctionListeners($class, $self = NULL) { + $listenerMap = []; + + /** + * @param string $underscore + * Ex: 'civi_foo_bar', 'hook_civicrm_foo' + * @return string + * Ex: 'civi.foo.bar', 'hook_civicrm_foo' + */ + $toEventName = function ($underscore) { + if (substr($underscore, 0, 5) === 'hook_') { + return $underscore; + } + else { + return str_replace('_', '.', $underscore); + } + }; + + $addListener = function ($event, $func, $priority = 0) use (&$listenerMap) { + $listenerMap[$event][] = [$func, $priority]; + }; + + foreach (get_class_methods($class) as $func) { + if (preg_match('/^(hook_|on_|self_)/', $func, $m)) { + switch ($m[1]) { + case 'hook_': + $addListener('&' . $func, $func); + break; + + case 'on_': + $addListener($toEventName(substr($func, 3)), $func); + break; + + case 'self_': + if ($self === NULL) { + throw new \RuntimeException("Cannot add self_*() listeners for $class"); + } + $addListener($toEventName(substr($func, 5)) . '::' . $self, $func); + break; + } + } + } + + return $listenerMap; + } + + /** + * Convert the listeners to a standard flavor. + * + * @param array $listenerMap + * List of events/listeners. Listeners may be given in singular or plural form. + * Ex: ['some.event' => 'oneListener'] + * Ex: ['some.event' => ['oneListener', 100]] + * Ex: ['some.event' => [['firstListener', 100], ['secondListener']]] + * @return array + * List of events/listeners. All listeners are described in plural form. + * Ex: ['some.event' => [['firstListener', 100], ['secondListener']]] + */ + protected static function normalizeListenerMap($listenerMap) { + $r = []; + foreach ($listenerMap as $eventName => $params) { + $r[$eventName] = []; + if (\is_string($params)) { + $r[$eventName][] = [$params]; + } + elseif (\is_string($params[0])) { + $r[$eventName][] = $params; + } + else { + $r[$eventName] = array_merge($r[$eventName], $params); + } + } + return $r; + } + + protected static function mergeListenerMap($left, $right) { + if ($left === []) { + return $right; + } + foreach ($right as $eventName => $listeners) { + $left[$eventName] = array_merge($left[$eventName] ?? [], $listeners); + } + return $left; + } + +} diff --git a/Civi/Core/Event/HookStyleListener.php b/Civi/Core/Event/HookStyleListener.php new file mode 100644 index 0000000000..e901f30c86 --- /dev/null +++ b/Civi/Core/Event/HookStyleListener.php @@ -0,0 +1,41 @@ +addListener('hook_civicrm_foo', new HookStyleListener('listen_to_hook_foo')); + * ``` + * + * @package Civi\Core\Event + */ +class HookStyleListener { + + /** + * @var array + * Ex: ['SomeClass', 'someMethod'] + */ + private $callback = NULL; + + /** + * @param array $callback + * Ex: ['SomeClass', 'someMethod'] + */ + public function __construct($callback) { + $this->callback = $callback; + } + + public function __invoke(GenericHookEvent $e) { + return call_user_func_array($this->callback, $e->getHookValues()); + } + + public function __toString() { + $name = EventPrinter::formatName($this->callback); + return preg_replace('/\(\$?e?\)$/', '(&...)', $name); + } + +} diff --git a/Civi/Core/Event/ServiceListener.php b/Civi/Core/Event/ServiceListener.php index ae5b70c8f6..63df85bc71 100644 --- a/Civi/Core/Event/ServiceListener.php +++ b/Civi/Core/Event/ServiceListener.php @@ -64,10 +64,10 @@ class ServiceListener { } } if ($class) { - return sprintf('$(%s)->%s() [%s]', $this->inertCb[0], $this->inertCb[1], $class); + return sprintf('$(%s)->%s($e) [%s]', $this->inertCb[0], $this->inertCb[1], $class); } else { - return sprintf('\$(%s)->%s()', $this->inertCb[0], $this->inertCb[1]); + return sprintf('\$(%s)->%s($e)', $this->inertCb[0], $this->inertCb[1]); } } -- 2.25.1