Merge pull request #20486 from civicrm/5.38
[civicrm-core.git] / Civi / Core / CiviEventDispatcher.php
1 <?php
2
3 namespace Civi\Core;
4
5 use Civi\Core\Event\HookStyleListener;
6 use Symfony\Component\EventDispatcher\EventDispatcher;
7 use Symfony\Component\EventDispatcher\Event;
8
9 /**
10 * Class CiviEventDispatcher
11 * @package Civi\Core
12 *
13 * The CiviEventDispatcher is a Symfony dispatcher. Additionally, if an event
14 * follows the naming convention of "hook_*", then it will also be dispatched
15 * through CRM_Utils_Hook::invoke().
16 *
17 * @see \CRM_Utils_Hook
18 */
19 class CiviEventDispatcher extends EventDispatcher {
20
21 const DEFAULT_HOOK_PRIORITY = -100;
22
23 /**
24 * Track the list of hook-events for which we have autoregistered
25 * the hook adapter.
26 *
27 * @var array
28 * Array(string $eventName => trueish).
29 */
30 private $autoListeners = [];
31
32 /**
33 * A list of dispatch-policies (based on an exact-match to the event name).
34 *
35 * Note: $dispatchPolicyExact and $dispatchPolicyRegex should coexist; e.g.
36 * if one is NULL, then both are NULL. If one is an array, then both are arrays.
37 *
38 * @var array|null
39 * Array(string $eventName => string $action)
40 */
41 private $dispatchPolicyExact = NULL;
42
43 /**
44 * A list of dispatch-policies (based on an regex-match to the event name).
45 *
46 * Note: $dispatchPolicyExact and $dispatchPolicyRegex should coexist; e.g.
47 * if one is NULL, then both are NULL. If one is an array, then both are arrays.
48 *
49 * @var array|null
50 * Array(string $eventRegex => string $action)
51 */
52 private $dispatchPolicyRegex = NULL;
53
54 /**
55 * Determine whether $eventName should delegate to the CMS hook system.
56 *
57 * @param string $eventName
58 * Ex: 'civi.token.eval', 'hook_civicrm_post`.
59 * @return bool
60 */
61 protected function isHookEvent($eventName) {
62 return (substr($eventName, 0, 5) === 'hook_') && (strpos($eventName, '::') === FALSE);
63 }
64
65 /**
66 * Adds a series of event listeners from a subscriber object.
67 *
68 * This is particularly useful if you want to register the subscriber without
69 * materializing the subscriber object.
70 *
71 * @param string $subscriber
72 * Service ID of the subscriber.
73 * @param array $events
74 * List of events/methods/priorities.
75 * @see \Symfony\Component\EventDispatcher\EventSubscriberInterface::getSubscribedEvents()
76 */
77 public function addSubscriberServiceMap(string $subscriber, array $events) {
78 foreach ($events as $eventName => $params) {
79 if (\is_string($params)) {
80 $this->addListenerService($eventName, [$subscriber, $params]);
81 }
82 elseif (\is_string($params[0])) {
83 $this->addListenerService($eventName, [$subscriber, $params[0]], isset($params[1]) ? $params[1] : 0);
84 }
85 else {
86 foreach ($params as $listener) {
87 $this->addListenerService($eventName, [$subscriber, $listener[0]], isset($listener[1]) ? $listener[1] : 0);
88 }
89 }
90 }
91 }
92
93 /**
94 * Add a test listener.
95 *
96 * @param string $eventName
97 * Ex: 'civi.internal.event'
98 * Ex: 'hook_civicrm_publicEvent'
99 * Ex: '&hook_civicrm_publicEvent' (an alias for 'hook_civicrm_publicEvent' in which the listener abides hook-style ordered parameters).
100 * This notation is handy when attaching via listener-maps (e.g. `getSubscribedEvents()`).
101 * @param callable $listener
102 * @param int $priority
103 */
104 public function addListener($eventName, $listener, $priority = 0) {
105 if ($eventName[0] === '&') {
106 $eventName = substr($eventName, 1);
107 $listener = new HookStyleListener($listener);
108 }
109 parent::addListener($eventName, $listener, $priority);
110 }
111
112 /**
113 * Adds a series of event listeners from methods in a class.
114 *
115 * @param string|object $target
116 * The object/class which will receive the notifications.
117 * Use a string (class-name) if the listeners are static methods.
118 * Use an object-instance if the listeners are regular methods.
119 * @param array $events
120 * List of events/methods/priorities.
121 * @see \Symfony\Component\EventDispatcher\EventSubscriberInterface::getSubscribedEvents()
122 */
123 public function addListenerMap($target, array $events) {
124 foreach ($events as $eventName => $params) {
125 if (\is_string($params)) {
126 $this->addListener($eventName, [$target, $params]);
127 }
128 elseif (\is_string($params[0])) {
129 $this->addListener($eventName, [$target, $params[0]], isset($params[1]) ? $params[1] : 0);
130 }
131 else {
132 foreach ($params as $listener) {
133 $this->addListener($eventName, [$target, $listener[0]], isset($listener[1]) ? $listener[1] : 0);
134 }
135 }
136 }
137 }
138
139 /**
140 * Adds a service as event listener.
141 *
142 * This provides partial backwards compatibility with ContainerAwareEventDispatcher.
143 *
144 * @param string $eventName Event for which the listener is added
145 * @param array $callback The service ID of the listener service & the method
146 * name that has to be called
147 * @param int $priority The higher this value, the earlier an event listener
148 * will be triggered in the chain.
149 * Defaults to 0.
150 *
151 * @throws \InvalidArgumentException
152 */
153 public function addListenerService($eventName, $callback, $priority = 0) {
154 if (!\is_array($callback) || 2 !== \count($callback)) {
155 throw new \InvalidArgumentException('Expected an array("service", "method") argument');
156 }
157
158 $this->addListener($eventName, new \Civi\Core\Event\ServiceListener($callback), $priority);
159 }
160
161 /**
162 * @inheritDoc
163 */
164 public function dispatch($eventName, Event $event = NULL) {
165 // Dispatch policies add systemic overhead and (normally) should not be evaluated. JNZ.
166 if ($this->dispatchPolicyRegex !== NULL) {
167 switch ($mode = $this->checkDispatchPolicy($eventName)) {
168 case 'run':
169 // Continue on the normal execution.
170 break;
171
172 case 'drop':
173 // Quietly ignore the event.
174 return $event;
175
176 case 'warn':
177 // Run the event, but complain about it.
178 error_log("Unexpectedly dispatching event \"$eventName\".");
179 break;
180
181 case 'warn-drop':
182 // Ignore the event, but complaint about it.
183 error_log("Unexpectedly dispatching event \"$eventName\".");
184 return $event;
185
186 case 'fail':
187 throw new \RuntimeException("The dispatch policy prohibits event \"$eventName\".");
188
189 case 'not-ready':
190 throw new \RuntimeException("CiviCRM has not bootstrapped sufficiently to fire event \"$eventName\".");
191
192 default:
193 throw new \RuntimeException("The dispatch policy for \"$eventName\" is unrecognized ($mode).");
194
195 }
196 }
197 $this->bindPatterns($eventName);
198 return parent::dispatch($eventName, $event);
199 }
200
201 /**
202 * @inheritDoc
203 */
204 public function getListeners($eventName = NULL) {
205 $this->bindPatterns($eventName);
206 return parent::getListeners($eventName);
207 }
208
209 /**
210 * @inheritDoc
211 */
212 public function hasListeners($eventName = NULL) {
213 // All hook_* events have default listeners, so hasListeners(NULL) is a truism.
214 return ($eventName === NULL || $this->isHookEvent($eventName))
215 ? TRUE : parent::hasListeners($eventName);
216 }
217
218 /**
219 * Invoke hooks using an event object.
220 *
221 * @param \Civi\Core\Event\GenericHookEvent $event
222 * @param string $eventName
223 * Ex: 'hook_civicrm_dashboard'.
224 */
225 public static function delegateToUF($event, $eventName) {
226 $hookName = substr($eventName, 5);
227 $hooks = \CRM_Utils_Hook::singleton();
228 $params = $event->getHookValues();
229 $count = count($params);
230
231 switch ($count) {
232 case 0:
233 $fResult = $hooks->invokeViaUF($count, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, $hookName);
234 break;
235
236 case 1:
237 $fResult = $hooks->invokeViaUF($count, $params[0], \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, $hookName);
238 break;
239
240 case 2:
241 $fResult = $hooks->invokeViaUF($count, $params[0], $params[1], \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, $hookName);
242 break;
243
244 case 3:
245 $fResult = $hooks->invokeViaUF($count, $params[0], $params[1], $params[2], \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, $hookName);
246 break;
247
248 case 4:
249 $fResult = $hooks->invokeViaUF($count, $params[0], $params[1], $params[2], $params[3], \CRM_Utils_Hook::$_nullObject, \CRM_Utils_Hook::$_nullObject, $hookName);
250 break;
251
252 case 5:
253 $fResult = $hooks->invokeViaUF($count, $params[0], $params[1], $params[2], $params[3], $params[4], \CRM_Utils_Hook::$_nullObject, $hookName);
254 break;
255
256 case 6:
257 $fResult = $hooks->invokeViaUF($count, $params[0], $params[1], $params[2], $params[3], $params[4], $params[5], $hookName);
258 break;
259
260 default:
261 throw new \RuntimeException("hook_{$hookName} cannot support more than 6 parameters");
262 }
263
264 $event->addReturnValues($fResult);
265 }
266
267 /**
268 * Attach any pattern-based listeners which may be interested in $eventName.
269 *
270 * @param string $eventName
271 * Ex: 'civi.api.resolve' or 'hook_civicrm_dashboard'.
272 */
273 protected function bindPatterns($eventName) {
274 if ($eventName !== NULL && !isset($this->autoListeners[$eventName])) {
275 $this->autoListeners[$eventName] = 1;
276 if ($this->isHookEvent($eventName)) {
277 // WISHLIST: For native extensions (and possibly D6/D7/D8/BD), enumerate
278 // the listeners and list them one-by-one. This would make it easier to
279 // inspect via "cv debug:event-dispatcher".
280 $this->addListener($eventName, [
281 '\Civi\Core\CiviEventDispatcher',
282 'delegateToUF',
283 ], self::DEFAULT_HOOK_PRIORITY);
284 }
285 }
286 }
287
288 /**
289 * Set the dispatch policy. This allows you to filter certain events.
290 * This can be useful during upgrades or debugging.
291 *
292 * Enforcement will add systemic overhead, so this should normally be NULL.
293 *
294 * @param array|null $dispatchPolicy
295 * Each key is either the string-literal name of an event, or a regex delimited by '/'.
296 * Each value is one of: 'run', 'drop', 'warn', 'fail'.
297 * Exact name matches take precedence over regexes. Regexes are evaluated in order.
298 *
299 * Ex: ['hook_civicrm_pre' => 'fail']
300 * Ex: ['/^hook_/' => 'warn']
301 *
302 * @return static
303 */
304 public function setDispatchPolicy($dispatchPolicy) {
305 if (is_array($dispatchPolicy)) {
306 // Split $dispatchPolicy in two (exact rules vs regex rules).
307 $this->dispatchPolicyExact = [];
308 $this->dispatchPolicyRegex = [];
309 foreach ($dispatchPolicy as $pattern => $action) {
310 if ($pattern[0] === '/') {
311 $this->dispatchPolicyRegex[$pattern] = $action;
312 }
313 else {
314 $this->dispatchPolicyExact[$pattern] = $action;
315 }
316 }
317 }
318 else {
319 $this->dispatchPolicyExact = NULL;
320 $this->dispatchPolicyRegex = NULL;
321 }
322
323 return $this;
324 }
325
326 // /**
327 // * @return array|NULL
328 // */
329 // public function getDispatchPolicy() {
330 // return $this->dispatchPolicyRegex === NULL ? NULL : array_merge($this->dispatchPolicyExact, $this->dispatchPolicyRegex);
331 // }
332
333 /**
334 * Determine whether the dispatch policy applies to a given event.
335 *
336 * @param string $eventName
337 * Ex: 'civi.api.resolve' or 'hook_civicrm_dashboard'.
338 * @return string
339 * Ex: 'run', 'drop', 'fail'
340 */
341 protected function checkDispatchPolicy($eventName) {
342 if (isset($this->dispatchPolicyExact[$eventName])) {
343 return $this->dispatchPolicyExact[$eventName];
344 }
345 foreach ($this->dispatchPolicyRegex as $eventPat => $action) {
346 if ($eventPat[0] === '/' && preg_match($eventPat, $eventName)) {
347 return $action;
348 }
349 }
350 return 'fail';
351 }
352
353 }