MDL-79915 backup: Improve the Restore main page
[moodle.git] / lib / classes / hook / manager.php
blob1b29e4f2addffea55817177657bca3a8892d5914
1 <?php
2 // This file is part of Moodle - https://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <https://www.gnu.org/licenses/>.
17 namespace core\hook;
19 use Psr\EventDispatcher\EventDispatcherInterface;
20 use Psr\EventDispatcher\ListenerProviderInterface;
21 use Psr\EventDispatcher\StoppableEventInterface;
23 /**
24 * Hook manager implementing "Dispatcher" and "Event Provider" from PSR-14.
26 * Due to class/method naming restrictions and collision with
27 * Moodle events the definitions from PSR-14 should be interpreted as:
29 * 1. Event --> Hook
30 * 2. Listener --> Hook callback
31 * 3. Emitter --> Hook emitter
32 * 4. Dispatcher --> Hook dispatcher - implemented in manager::dispatch()
33 * 5. Listener Provider --> Hook callback provider - implemented in manager::get_callbacks_for_hook()
35 * Note that technically any object can be a hook, but it is recommended
36 * to put all hook classes into \component_name\hook namespaces and
37 * each hook should implement \core\hook\described_hook interface.
39 * @package core
40 * @author Petr Skoda
41 * @copyright 2022 Open LMS
42 * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44 final class manager implements
45 EventDispatcherInterface,
46 ListenerProviderInterface {
48 /** @var ?manager the one instance of listener provider and dispatcher */
49 private static $instance = null;
51 /** @var array list of callback definitions for each hook class. */
52 private $allcallbacks = [];
54 /** @var array list of all deprecated lib.php plugin callbacks. */
55 private $alldeprecations = [];
57 /** @var array list of redirected callbacks in PHPUnit tests */
58 private $redirectedcallbacks = [];
60 /**
61 * Constructor can be used only from factory methods.
63 private function __construct() {
66 /**
67 * Factory method, returns instance of manager that serves
68 * as hook dispatcher and callback provider.
70 * @return self
72 public static function get_instance(): manager {
73 if (!self::$instance) {
74 self::$instance = new self();
75 self::$instance->init_standard_callbacks();
77 return self::$instance;
80 /**
81 * Factory method for testing of hook manager in PHPUnit tests.
83 * @param array $componentfiles list of hook callback files for each component.
84 * @return self
86 public static function phpunit_get_instance(array $componentfiles): manager {
87 if (!PHPUNIT_TEST) {
88 throw new \coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests');
90 $instance = new self();
91 $instance->load_callbacks($componentfiles);
92 return $instance;
95 /**
96 * Override hook callbacks for testing purposes.
98 * @param string $hookname
99 * @param callable $callback
100 * @return void
102 public function phpunit_redirect_hook(string $hookname, callable $callback): void {
103 if (!PHPUNIT_TEST) {
104 throw new \coding_exception('Invalid call of manager::phpunit_redirect_hook() outside of tests');
106 $this->redirectedcallbacks[$hookname] = $callback;
110 * Cancel all redirections of hook callbacks.
112 * @return void
114 public function phpunit_stop_redirections(): void {
115 if (!PHPUNIT_TEST) {
116 throw new \coding_exception('Invalid call of manager::phpunit_stop_redirections() outside of tests');
118 $this->redirectedcallbacks = [];
122 * Returns list of callbacks for given hook name.
124 * NOTE: this is the "Listener Provider" described in PSR-14,
125 * instead of instance parameter it uses real PHP class names.
127 * @param string $hookclassname PHP class name of hook
128 * @return array list of callback definitions
130 public function get_callbacks_for_hook(string $hookclassname): array {
131 return $this->allcallbacks[$hookclassname] ?? [];
135 * Returns list of all callbacks found in db/hooks.php files.
137 * @return iterable
139 public function get_all_callbacks(): iterable {
140 return $this->allcallbacks;
144 * Get the list of listeners for the specified event.
146 * @param object $event The object being listened to (aka hook).
147 * @return iterable<callable>
148 * An iterable (array, iterator, or generator) of callables. Each
149 * callable MUST be type-compatible with $event.
150 * Please note that in Moodle the callable must be a string.
152 public function getListenersForEvent(object $event): iterable {
153 // Callbacks are sorted by priority, highest first at load-time.
154 $hookclassname = get_class($event);
155 $callbacks = $this->get_callbacks_for_hook($hookclassname);
157 if (count($callbacks) === 0) {
158 // Nothing is interested in this hook.
159 return new \EmptyIterator();
162 foreach ($callbacks as $definition) {
163 if ($definition['disabled']) {
164 continue;
166 $callback = $definition['callback'];
168 if ($this->is_callback_valid($definition['component'], $callback)) {
169 yield $callback;
175 * Verify that callback is valid.
177 * @param string $component
178 * @param string $callback
179 * @return bool
181 private function is_callback_valid(string $component, string $callback): bool {
182 [$callbackclass, $callbackmethod] = explode('::', $callback, 2);
183 if (!class_exists($callbackclass)) {
184 debugging(
185 "Hook callback definition contains invalid 'callback' class name in '$component'. " .
186 "Callback class '{$callbackclass}' not found.",
187 DEBUG_DEVELOPER,
189 return false;
191 $rc = new \ReflectionClass($callbackclass);
192 if (!$rc->hasMethod($callbackmethod)) {
193 debugging(
194 "Hook callback definition contains invalid 'callback' method name in '$component'. " .
195 "Callback method not found.",
196 DEBUG_DEVELOPER,
198 return false;
201 $rcm = $rc->getMethod($callbackmethod);
202 if (!$rcm->isStatic()) {
203 debugging(
204 "Hook callback definition contains invalid 'callback' method name in '$component'. " .
205 "Callback method not a static method.",
206 DEBUG_DEVELOPER,
208 return false;
211 if (!is_callable($callback, false, $callablename)) {
212 debugging(
213 "Cannot execute callback '$callablename' from '$component'" .
214 "Callback method not callable.",
215 DEBUG_DEVELOPER
217 return false;
220 return true;
224 * Returns the list of Hook class names that have registered callbacks.
226 * @return array
228 public function get_hooks_with_callbacks(): array {
229 return array_keys($this->allcallbacks);
233 * Provide all relevant listeners with an event to process.
235 * @param object $event The object to process (aka hook).
236 * @return object The Event that was passed, now modified by listeners.
238 public function dispatch(object $event): object {
239 // We can dispatch only after the lib/setup.php includes,
240 // that is right before the database connection is made,
241 // the MUC caches need to be working already.
242 if (!function_exists('setup_DB')) {
243 debugging('Hooks cannot be dispatched yet', DEBUG_DEVELOPER);
244 return $event;
247 if (PHPUNIT_TEST) {
248 $hookclassname = get_class($event);
249 if (isset($this->redirectedcallbacks[$hookclassname])) {
250 call_user_func($this->redirectedcallbacks[$hookclassname], $event);
251 return $event;
255 $callbacks = $this->getListenersForEvent($event);
257 if (empty($callbacks)) {
258 // Nothing is interested in this hook.
259 return $event;
262 foreach ($callbacks as $callback) {
263 // Note: PSR-14 states:
264 // If passed a Stoppable Event, a Dispatcher
265 // MUST call isPropagationStopped() on the Event before each Listener has been called.
266 // If that method returns true it MUST return the Event to the Emitter immediately and
267 // MUST NOT call any further Listeners. This implies that if an Event is passed to the
268 // Dispatcher that always returns true from isPropagationStopped(), zero listeners will be called.
269 // Ergo, we check for a stopped event before calling each listener, not afterwards.
270 if ($event instanceof StoppableEventInterface) {
271 if ($event->isPropagationStopped()) {
272 return $event;
276 call_user_func($callback, $event);
279 // Developers need to be careful to not create infinite loops in hook callbacks.
280 return $event;
284 * Initialise list of all callbacks for each hook.
286 * @return void
288 private function init_standard_callbacks(): void {
289 global $CFG;
291 $this->allcallbacks = [];
292 $this->alldeprecations = [];
294 $cache = null;
295 // @codeCoverageIgnoreStart
296 if (!PHPUNIT_TEST && !CACHE_DISABLE_ALL) {
297 $cache = \cache::make('core', 'hookcallbacks');
298 $callbacks = $cache->get('callbacks');
299 $deprecations = $cache->get('deprecations');
300 $overrideshash = $cache->get('overrideshash');
302 $usecache = is_array($callbacks);
303 $usecache = $usecache && is_array($deprecations);
304 $usecache = $usecache && $this->calculate_overrides_hash() === $overrideshash;
305 if ($usecache) {
306 $this->allcallbacks = $callbacks;
307 $this->alldeprecations = $deprecations;
308 return;
311 // @codeCoverageIgnoreEnd
313 // Get list of all files with callbacks, one per component.
314 $components = ['core' => "{$CFG->dirroot}/lib/db/hooks.php"];
315 $plugintypes = \core_component::get_plugin_types();
316 foreach ($plugintypes as $plugintype => $plugintypedir) {
317 $plugins = \core_component::get_plugin_list($plugintype);
318 foreach ($plugins as $pluginname => $plugindir) {
319 if (!$plugindir) {
320 continue;
323 $components["{$plugintype}_{$pluginname}"] = "{$plugindir}/db/hooks.php";
327 // Load the callbacks and apply overrides.
328 $this->load_callbacks($components);
330 if ($cache) {
331 $cache->set('callbacks', $this->allcallbacks);
332 $cache->set('deprecations', $this->alldeprecations);
333 $cache->set('overrideshash', $this->calculate_overrides_hash());
338 * Load callbacks from component db/hooks.php files.
340 * @param array $componentfiles list of all components with their callback files
341 * @return void
343 private function load_callbacks(array $componentfiles): void {
344 $this->allcallbacks = [];
345 $this->alldeprecations = [];
347 array_map(
348 [$this, 'add_component_callbacks'],
349 array_keys($componentfiles),
350 $componentfiles,
352 $this->load_callback_overrides();
353 $this->prioritise_callbacks();
354 $this->fetch_deprecated_callbacks();
358 * In extremely special cases admins may decide to override callbacks via config.php setting.
360 private function load_callback_overrides(): void {
361 global $CFG;
363 if (!property_exists($CFG, 'hooks_callback_overrides')) {
364 return;
367 if (!is_iterable($CFG->hooks_callback_overrides)) {
368 debugging('hooks_callback_overrides must be an array', DEBUG_DEVELOPER);
369 return;
372 foreach ($CFG->hooks_callback_overrides as $hookclassname => $overrides) {
373 if (!is_iterable($overrides)) {
374 debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
375 continue;
378 if (!array_key_exists($hookclassname, $this->allcallbacks)) {
379 debugging('hooks_callback_overrides must be an array of arrays with existing hook classnames', DEBUG_DEVELOPER);
380 continue;
383 foreach ($overrides as $callback => $override) {
384 if (!is_array($override)) {
385 debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
386 continue;
389 $found = false;
390 foreach ($this->allcallbacks[$hookclassname] as $index => $definition) {
391 if ($definition['callback'] === $callback) {
392 if (isset($override['priority'])) {
393 $definition['defaultpriority'] = $definition['priority'];
394 $definition['priority'] = (int) $override['priority'];
397 if (!empty($override['disabled'])) {
398 $definition['disabled'] = true;
401 $this->allcallbacks[$hookclassname][$index] = $definition;
402 $found = true;
403 break;
406 if (!$found) {
407 debugging("Unable to find callback '{$callback}' for '{$hookclassname}'", DEBUG_DEVELOPER);
414 * Calculate a hash of the overrides.
415 * This is used to inform if the overrides have changed, which invalidates the cache.
417 * Overrides are only configured in config.php where there is no other mechanism to invalidate the cache.
419 * @return null|string
421 private function calculate_overrides_hash(): ?string {
422 global $CFG;
424 if (!property_exists($CFG, 'hooks_callback_overrides')) {
425 return null;
428 if (!is_iterable($CFG->hooks_callback_overrides)) {
429 return null;
432 return sha1(json_encode($CFG->hooks_callback_overrides));
436 * Prioritise the callbacks.
438 private function prioritise_callbacks(): void {
439 // Prioritise callbacks.
440 foreach ($this->allcallbacks as $hookclassname => $hookcallbacks) {
441 \core_collator::asort_array_of_arrays_by_key($hookcallbacks, 'priority', \core_collator::SORT_NUMERIC);
442 $hookcallbacks = array_reverse($hookcallbacks);
443 $this->allcallbacks[$hookclassname] = $hookcallbacks;
448 * Fetch the list of callbacks that this hook replaces.
450 private function fetch_deprecated_callbacks(): void {
451 $candidates = self::discover_known_hooks();
453 /** @var class-string<deprecated_callback_replacement> $hookclassname */
454 foreach (array_keys($candidates) as $hookclassname) {
455 if (!class_exists($hookclassname)) {
456 continue;
458 if (!is_subclass_of($hookclassname, \core\hook\deprecated_callback_replacement::class)) {
459 continue;
461 $deprecations = $hookclassname::get_deprecated_plugin_callbacks();
462 if (!$deprecations) {
463 continue;
465 foreach ($deprecations as $deprecation) {
466 $this->alldeprecations[$deprecation][] = $hookclassname;
472 * Add hook callbacks from file.
474 * @param string $component component where hook callbacks are defined
475 * @param string $hookfile file with list of all callbacks for component
476 * @return void
478 private function add_component_callbacks(string $component, string $hookfile): void {
479 if (!file_exists($hookfile)) {
480 return;
483 $parsecallbacks = function($hookfile) {
484 $callbacks = [];
485 include($hookfile);
486 return $callbacks;
489 $callbacks = $parsecallbacks($hookfile);
491 if (!is_array($callbacks) || !$callbacks) {
492 return;
495 foreach ($callbacks as $callbackdata) {
496 if (empty($callbackdata['hook'])) {
497 debugging("Hook callback definition requires 'hook' name in '$component'", DEBUG_DEVELOPER);
498 continue;
501 $callbackmethod = $this->normalise_callback($component, $callbackdata);
502 if ($callbackmethod === null) {
503 continue;
506 $callback = [
507 'callback' => $callbackmethod,
508 'component' => $component,
509 'disabled' => false,
510 'priority' => 100,
513 if (isset($callbackdata['priority'])) {
514 $callback['priority'] = (int) $callbackdata['priority'];
517 $hook = ltrim($callbackdata['hook'], '\\'); // Normalise hook class name.
518 $this->allcallbacks[$hook][] = $callback;
523 * Normalise the callback class::method value.
525 * @param string $component
526 * @param array $callback
527 * @return null|string
529 private function normalise_callback(string $component, array $callback): ?string {
530 if (empty($callback['callback'])) {
531 debugging("Hook callback definition requires 'callback' callable in '$component'", DEBUG_DEVELOPER);
532 return null;
534 $classmethod = $callback['callback'];
535 if (!is_string($classmethod)) {
536 debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER);
537 return null;
539 if (!str_contains($classmethod, '::')) {
540 debugging(
541 "Hook callback definition contains invalid 'callback' static class method string in '$component'",
542 DEBUG_DEVELOPER
544 return null;
547 // Normalise the callback class::method name, we use it later as an identifier.
548 $classmethod = ltrim($classmethod, '\\');
550 return $classmethod;
554 * Is the plugin callback from lib.php deprecated by any hook?
556 * @param string $plugincallback short callback name without the component prefix
557 * @return bool
559 public function is_deprecated_plugin_callback(string $plugincallback): bool {
560 return isset($this->alldeprecations[$plugincallback]);
564 * Is there a hook callback in component that deprecates given lib.php plugin callback?
566 * NOTE: if there is both hook and deprecated callback then we ignore the old callback
567 * to allow compatibility of contrib plugins with multiple Moodle branches.
569 * @param string $component
570 * @param string $plugincallback short callback name without the component prefix
571 * @return bool
573 public function is_deprecating_hook_present(string $component, string $plugincallback): bool {
574 if (!isset($this->alldeprecations[$plugincallback])) {
575 return false;
578 foreach ($this->alldeprecations[$plugincallback] as $hookclassname) {
579 if (!isset($this->allcallbacks[$hookclassname])) {
580 continue;
582 foreach ($this->allcallbacks[$hookclassname] as $definition) {
583 if ($definition['component'] === $component) {
584 return true;
589 return false;
593 * Returns list of hooks discovered through hook namespaces or discovery agents.
595 * The hooks overview page includes also all other classes that are
596 * referenced in callback registrations in db/hooks.php files, those
597 * are not included here.
599 * @return array hook class names
601 public static function discover_known_hooks(): array {
602 // All classes in hook namespace of core and plugins, unless plugin has a discovery agent.
603 $hooks = \core\hooks::discover_hooks();
605 // Look for hooks classes in all plugins that implement discovery agent interface.
606 foreach (\core_component::get_component_names() as $component) {
607 $classname = "{$component}\\hooks";
609 if (!class_exists($classname)) {
610 continue;
613 if (!is_subclass_of($classname, discovery_agent::class)) {
614 continue;
617 $hooks = array_merge($hooks, $classname::discover_hooks());
620 return $hooks;