diff --git a/admin/classes/table/hook_list_table.php b/admin/classes/table/hook_list_table.php new file mode 100644 index 00000000000..2dd6966455d --- /dev/null +++ b/admin/classes/table/hook_list_table.php @@ -0,0 +1,274 @@ +. + +namespace core_admin\table; + +use core_plugin_manager; +use flexible_table; +use html_writer; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); +require_once("{$CFG->libdir}/tablelib.php"); + +/** + * Plugin Management table. + * + * @package core_admin + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_list_table extends flexible_table { + + /** @var \core\plugininfo\base[] The plugin list */ + protected array $plugins = []; + + /** @var int The number of enabled plugins of this type */ + protected int $enabledplugincount = 0; + + /** @var core_plugin_manager */ + protected core_plugin_manager $pluginmanager; + + /** @var string The plugininfo class for this plugintype */ + protected string $plugininfoclass; + + /** @var stdClass[] The list of emitted hooks with metadata */ + protected array $emitters; + + public function __construct() { + global $CFG; + + $this->define_baseurl('/admin/hooks.php'); + parent::__construct('core_admin-hook_list_table'); + + // Add emitted hooks. + $this->emitters = \core\hook\manager::discover_known_hooks(); + + $this->setup_column_configuration(); + $this->setup(); + } + + /** + * Set up the column configuration for this table. + */ + protected function setup_column_configuration(): void { + $columnlist = [ + 'details' => get_string('hookname', 'core_admin'), + 'callbacks' => get_string('hookcallbacks', 'core_admin'), + 'deprecates' => get_string('hookdeprecates', 'core_admin'), + ]; + $this->define_columns(array_keys($columnlist)); + $this->define_headers(array_values($columnlist)); + + $columnswithhelp = [ + 'callbacks' => new \help_icon('hookcallbacks', 'admin'), + ]; + $columnhelp = array_map(function (string $column) use ($columnswithhelp): ?\renderable { + if (array_key_exists($column, $columnswithhelp)) { + return $columnswithhelp[$column]; + } + + return null; + }, array_keys($columnlist)); + $this->define_help_for_headers($columnhelp); + } + + /** + * Print the table. + */ + public function out(): void { + // All hook consumers referenced from the db/hooks.php files. + $hookmanager = \core\hook\manager::get_instance(); + $allhooks = $hookmanager->get_all_callbacks(); + + // Add any unused hooks. + foreach (array_keys($this->emitters) as $classname) { + if (isset($allhooks[$classname])) { + continue; + } + $allhooks[$classname] = []; + } + + foreach ($allhooks as $classname => $consumers) { + $this->add_data_keyed( + $this->format_row((object) [ + 'classname' => $classname, + 'callbacks' => $consumers, + ]), + $this->get_row_class($classname), + ); + } + + $this->finish_output(false); + } + + protected function col_details(stdClass $row): string { + return $row->classname . + $this->get_description($row) . + html_writer::div($this->get_tags_for_row($row)); + } + + /** + * Show the name column content. + * + * @param stdClass $row + * @return string + */ + protected function get_description(stdClass $row): string { + if (!array_key_exists($row->classname, $this->emitters)) { + return ''; + } + + return html_writer::tag( + 'small', + clean_text(markdown_to_html($this->emitters[$row->classname]['description']), FORMAT_HTML), + ); + } + + protected function col_deprecates(stdClass $row): string { + if (!class_exists($row->classname)) { + return ''; + } + + $rc = new \ReflectionClass($row->classname); + if (!$rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) { + return ''; + } + $deprecates = call_user_func([$row->classname, 'get_deprecated_plugin_callbacks']); + if (count($deprecates) === 0) { + return ''; + } + $content = html_writer::start_tag('ul'); + + foreach ($deprecates as $deprecatedmethod) { + $content .= html_writer::tag('li', $deprecatedmethod); + } + $content .= html_writer::end_tag('ul'); + return $content; + } + + protected function col_callbacks(stdClass $row): string { + global $CFG; + + $hookclass = $row->classname; + $cbinfo = []; + foreach ($row->callbacks as $definition) { + $iscallable = is_callable($definition['callback'], false, $callbackname); + $isoverridden = isset($CFG->hooks_callback_overrides[$hookclass][$definition['callback']]); + $info = "{$callbackname} ({$definition['priority']})"; + if (!$iscallable) { + $info .= ' '; + $info .= $this->get_tag( + get_string('error'), + 'danger', + get_string('hookcallbacknotcallable', 'core_admin', $callbackname), + ); + } + if ($isoverridden) { + // The lang string meaning should be close enough here. + $info .= $this->get_tag( + get_string('hookconfigoverride', 'core_admin'), + 'warning', + get_string('hookconfigoverride_help', 'core_admin'), + ); + } + + $cbinfo[] = $info; + } + + if ($cbinfo) { + $output = html_writer::start_tag('ol'); + foreach ($cbinfo as $callback) { + $class = ''; + if ($definition['disabled']) { + $class = 'dimmed_text'; + } + $output .= html_writer::tag('li', $callback, ['class' => $class]); + } + $output .= html_writer::end_tag('ol'); + return $output; + } else { + return ''; + } + } + + /** + * Get the HTML to display the badge with tooltip. + * + * @param string $tag The main text to display + * @param null|string $type The pill type + * @param null|string $tooltip The content of the tooltip + * @return string + */ + protected function get_tag( + string $tag, + ?string $type = null, + ?string $tooltip = null, + ): string { + $attributes = []; + + if ($type === null) { + $type = 'info'; + } + + if ($tooltip) { + $attributes['data-toggle'] = 'tooltip'; + $attributes['title'] = $tooltip; + } + return html_writer::span($tag, "badge badge-{$type}", $attributes); + } + + /** + * Get the code to display a set of tags for this table row. + * + * @param stdClass $row + * @return string + */ + protected function get_tags_for_row(stdClass $row): string { + if (!array_key_exists($row->classname, $this->emitters)) { + // This hook has been defined in the db/hooks.php file + // but does not refer to a hook in this version of Moodle. + return $this->get_tag( + get_string('hookunknown', 'core_admin'), + 'warning', + get_string('hookunknown_desc', 'core_admin'), + ); + } + + if (!class_exists($row->classname)) { + // This hook has been defined in a hook discovery agent, but the class it refers to could not be found. + return $this->get_tag( + get_string('hookclassmissing', 'core_admin'), + 'warning', + get_string('hookclassmissing_desc', 'core_admin'), + ); + } + + $tags = $this->emitters[$row->classname]['tags'] ?? []; + $taglist = array_map(function($tag): string { + if (is_array($tag)) { + return $this->get_tag(...$tag); + } + return $this->get_tag($tag, 'badge badge-info'); + }, $tags); + + return implode("\n", $taglist); + } + + protected function get_row_class(string $classname): string { + return ''; + } +} diff --git a/admin/hooks.php b/admin/hooks.php new file mode 100644 index 00000000000..e132b65525d --- /dev/null +++ b/admin/hooks.php @@ -0,0 +1,41 @@ +. + +/** + * Hooks overview page. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/tablelib.php'); + +admin_externalpage_setup('hooksoverview'); +require_capability('moodle/site:config', \core\context\system::instance()); + +$hookmanager = \core\hook\manager::get_instance(); + +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('hooksoverview', 'core_admin')); + +$table = new \core_admin\table\hook_list_table(); +$table->out(); + +echo $OUTPUT->footer(); diff --git a/admin/settings/development.php b/admin/settings/development.php index 9fb6f0c5042..a588b5eb24e 100644 --- a/admin/settings/development.php +++ b/admin/settings/development.php @@ -121,4 +121,7 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page "$CFG->wwwroot/$CFG->admin/purgecaches.php")); $ADMIN->add('development', new admin_externalpage('thirdpartylibs', new lang_string('thirdpartylibs','admin'), "$CFG->wwwroot/$CFG->admin/thirdpartylibs.php")); + + $ADMIN->add('development', new admin_externalpage('hooksoverview', + new lang_string('hooksoverview', 'admin'), "$CFG->wwwroot/$CFG->admin/hooks.php")); } // end of speedup diff --git a/lang/en/admin.php b/lang/en/admin.php index ef1508947fc..437139f8f35 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -722,6 +722,24 @@ $string['hiddenuserfields'] = 'Hide user fields'; $string['hidefromall'] = 'Hide from all users'; $string['hidefromnone'] = 'Hide from nobody'; $string['hidefromstudents'] = 'Hide from students'; +$string['hookcallbacks'] = 'Callbacks'; +$string['hookcallbacks_help'] = 'The list of callbacks which will be called when the hook is dispatched. + +The order shown is the order in which callbacks are called. + +A callback with a higher priority will be called before one with lower priority.'; +$string['hookcallbacknotcallable'] = 'This callback is not callable. This could be because the class or method does not exist, or because the method is not public.'; +$string['hookconfigoverride'] = 'Overridden'; +$string['hookconfigoverride_help'] = 'The definition of this callback has been overridden in the site configuration file, config.php'; +$string['hookdeprecates'] = 'Deprecated lib.php callbacks'; +$string['hookdescription'] = 'Description'; +$string['hookdescriptionmissing'] = 'Hook does not have a description method'; +$string['hookclassmissing'] = 'Hook class not found'; +$string['hookclassmissing_desc'] = 'The hook discovery agent has returned a class that does not exist.'; +$string['hookunknown'] = 'Hook not found'; +$string['hookunknown_desc'] = 'The object that this callback listens to is not available. It may have been removed or renamed, or it may not be available in this version of Moodle.'; +$string['hookname'] = 'Hook'; +$string['hooksoverview'] = 'Hooks overview'; $string['hostname'] = 'Host name'; $string['htmleditor'] = 'HTML editor'; $string['htmleditorsettings'] = 'HTML editor settings'; diff --git a/lang/en/cache.php b/lang/en/cache.php index aa644ce4beb..5d5a275a549 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -68,6 +68,7 @@ $string['cachedef_groupdata'] = 'Course group information'; $string['cachedef_h5p_content_type_translations'] = 'H5P content-type libraries translations'; $string['cachedef_h5p_libraries'] = 'H5P libraries'; $string['cachedef_h5p_library_files'] = 'H5P library files'; +$string['cachedef_hookcallbacks'] = 'Hook callbacks'; $string['cachedef_htmlpurifier'] = 'HTML Purifier - cleaned content'; $string['cachedef_langmenu'] = 'List of available languages'; $string['cachedef_license'] = 'List of licences'; diff --git a/lib/classes/component.php b/lib/classes/component.php index b9ba8a82a31..13b2fd13378 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -112,6 +112,7 @@ class core_component { 'Psr\\Http\\Client' => 'lib/psr/http-client/src', 'Psr\\Http\\Factory' => 'lib/psr/http-factory/src', 'Psr\\Http\\Message' => 'lib/psr/http-message/src', + 'Psr\\EventDispatcher' => 'lib/psr/event-dispatcher/src', 'GuzzleHttp\\Psr7' => 'lib/guzzlehttp/psr7/src', 'GuzzleHttp\\Promise' => 'lib/guzzlehttp/promises/src', 'GuzzleHttp' => 'lib/guzzlehttp/guzzle/src', diff --git a/lib/classes/hook/deprecated_callback_replacement.php b/lib/classes/hook/deprecated_callback_replacement.php new file mode 100644 index 00000000000..a2cae6f2795 --- /dev/null +++ b/lib/classes/hook/deprecated_callback_replacement.php @@ -0,0 +1,38 @@ +. + +namespace core\hook; + +/** + * Interface for hook callbacks that were deprecated by the hook. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface deprecated_callback_replacement { + /** + * Returns list of lib.php plugin callbacks that were deprecated by the hook. + * + * It is used for automatic debugging messages and if present it + * also skips relevant legacy callbacks in plugins that implemented callbacks + * for this hook (to allow plugin compatibility with multiple Moodle branches). + * + * @return array + */ + public static function get_deprecated_plugin_callbacks(): array; +} diff --git a/lib/classes/hook/described_hook.php b/lib/classes/hook/described_hook.php new file mode 100644 index 00000000000..484a4a44884 --- /dev/null +++ b/lib/classes/hook/described_hook.php @@ -0,0 +1,38 @@ +. + +namespace core\hook; + +/** + * Interface for a hook to provide a description of itself for administrator information. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface described_hook { + /** + * Mandatory hook purpose description in Markdown format + * used on Hooks overview page. + * + * It should include description of callback priority setting + * rules if applicable. + * + * @return string + */ + public static function get_hook_description(): string; +} diff --git a/lib/classes/hook/discovery_agent.php b/lib/classes/hook/discovery_agent.php new file mode 100644 index 00000000000..3cbdb6d2ae2 --- /dev/null +++ b/lib/classes/hook/discovery_agent.php @@ -0,0 +1,33 @@ +. + +namespace core\hook; + +/** + * This interface describes a component which can discover hooks in its own namespace. + * + * @package core + * @copyright Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface discovery_agent { + /** + * Discover hooks belonging to the component. + * + * @return array + */ + public static function discover_hooks(): array; +} diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php new file mode 100644 index 00000000000..3b0c1666db8 --- /dev/null +++ b/lib/classes/hook/manager.php @@ -0,0 +1,586 @@ +. + +namespace core\hook; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\ListenerProviderInterface; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook manager implementing "Dispatcher" and "Event Provider" from PSR-14. + * + * Due to class/method naming restrictions and collision with + * Moodle events the definitions from PSR-14 should be interpreted as: + * + * 1. Event --> Hook + * 2. Listener --> Hook callback + * 3. Emitter --> Hook emitter + * 4. Dispatcher --> Hook dispatcher - implemented in manager::dispatch() + * 5. Listener Provider --> Hook callback provider - implemented in manager::get_callbacks_for_hook() + * + * Note that technically any object can be a hook, but it is recommended + * to put all hook classes into \component_name\hook namespaces and + * each hook should implement \core\hook\described_hook interface. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class manager implements + EventDispatcherInterface, + ListenerProviderInterface { + + /** @var ?manager the one instance of listener provider and dispatcher */ + private static $instance = null; + + /** @var array list of callback definitions for each hook class. */ + private $allcallbacks = []; + + /** @var array list of all deprecated lib.php plugin callbacks. */ + private $alldeprecations = []; + + /** + * Constructor can be used only from factory methods. + */ + private function __construct() { + } + + /** + * Factory method, returns instance of manager that serves + * as hook dispatcher and callback provider. + * + * @return self + */ + public static function get_instance(): manager { + if (!self::$instance) { + self::$instance = new self(); + self::$instance->init_standard_callbacks(); + } + return self::$instance; + } + + /** + * Factory method for testing of hook manager in PHPUnit tests. + * + * @param array $componentfiles list of hook callback files for each component. + * @return self + */ + public static function phpunit_get_instance(array $componentfiles): manager { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests'); + } + $instance = new self(); + $instance->load_callbacks($componentfiles); + return $instance; + } + + /** + * Reset all hook caches. This is intended to be called only + * from the admin/hooks.php page after callback override is changed. + * + * @return void + * @codeCoverageIgnore + */ + public function reset_caches(): void { + if (PHPUNIT_TEST && $this === self::$instance) { + debugging('\core\hook\manager::get_instance()->reset_caches() is not supposed to be called in PHPUnit tests', + DEBUG_DEVELOPER); + return; + } + + // WARNING: This will not work when callback overrides are changed + // and multiple web nodes with local cache stores are present - in that + // case admins must purge all caches when tweaking callback overrides. + $cache = \cache::make('core', 'hookcallbacks'); + $cache->delete('callbacks'); + $cache->delete('deprecations'); + + $this->init_standard_callbacks(); + } + + /** + * Returns list of callbacks for given hook name. + * + * NOTE: this is the "Listener Provider" described in PSR-14, + * instead of instance parameter it uses real PHP class names. + * Moodle hooks should be final and parents of hook class are not + * considered when resolving callbacks. + * + * @param string $hookclassname PHP class name of hook + * @return array list of callback definitions + */ + public function get_callbacks_for_hook(string $hookclassname): array { + return $this->allcallbacks[$hookclassname] ?? []; + } + + /** + * Returns list of all callbacks found in db/hooks.php files. + * + * @return iterable + */ + public function get_all_callbacks(): iterable { + return $this->allcallbacks; + } + + /** + * Get the list of listeners for the specified event. + * + * @param object $event The object being listened to (aka hook). + * @return iterable + * An iterable (array, iterator, or generator) of callables. Each + * callable MUST be type-compatible with $event. + * Please note that in Moodle the callable must be a string. + */ + public function getListenersForEvent(object $event): iterable { + // Callbacks are sorted by priority, highest first at load-time. + $hookclassname = get_class($event); + $callbacks = $this->get_callbacks_for_hook($hookclassname); + + if (count($callbacks) === 0) { + // Nothing is interested in this hook. + return new \EmptyIterator(); + } + + foreach ($callbacks as $definition) { + if ($definition['disabled']) { + continue; + } + $callback = $definition['callback']; + + if ($this->is_callback_valid($definition['component'], $callback)) { + yield $callback; + } + } + } + + /** + * Verify that callback is valid. + * + * @param string $component + * @param string $callback + * @return bool + */ + private function is_callback_valid(string $component, string $callback): bool { + [$callbackclass, $callbackmethod] = explode('::', $callback, 2); + if (!class_exists($callbackclass)) { + debugging( + "Hook callback definition contains invalid 'callback' class name in '$component'. " . + "Callback class '{$callbackclass}' not found.", + DEBUG_DEVELOPER, + ); + return false; + } + $rc = new \ReflectionClass($callbackclass); + if (!$rc->hasMethod($callbackmethod)) { + debugging( + "Hook callback definition contains invalid 'callback' method name in '$component'. " . + "Callback method not found.", + DEBUG_DEVELOPER, + ); + return false; + } + + $rcm = $rc->getMethod($callbackmethod); + if (!$rcm->isStatic()) { + debugging( + "Hook callback definition contains invalid 'callback' method name in '$component'. " . + "Callback method not a static method.", + DEBUG_DEVELOPER, + ); + return false; + } + + if (!is_callable($callback, false, $callablename)) { + debugging( + "Cannot execute callback '$callablename' from '$component'" . + "Callback method not callable.", + DEBUG_DEVELOPER + ); + return false; + } + + return true; + } + + /** + * Returns the list of Hook class names that have registered callbacks. + * + * @return array + */ + public function get_hooks_with_callbacks(): array { + return array_keys($this->allcallbacks); + } + + /** + * Provide all relevant listeners with an event to process. + * + * @param object $event The object to process (aka hook). + * @return object The Event that was passed, now modified by listeners. + */ + public function dispatch(object $event): object { + // We can dispatch only after the lib/setup.php includes, + // that is right before the database connection is made, + // the MUC caches need to be working already. + if (!function_exists('setup_DB')) { + debugging('Hooks cannot be dispatched yet', DEBUG_DEVELOPER); + return $event; + } + + $callbacks = $this->getListenersForEvent($event); + + if (empty($callbacks)) { + // Nothing is interested in this hook. + return $event; + } + + foreach ($callbacks as $callback) { + // Note: PSR-14 states: + // If passed a Stoppable Event, a Dispatcher + // MUST call isPropagationStopped() on the Event before each Listener has been called. + // If that method returns true it MUST return the Event to the Emitter immediately and + // MUST NOT call any further Listeners. This implies that if an Event is passed to the + // Dispatcher that always returns true from isPropagationStopped(), zero listeners will be called. + // Ergo, we check for a stopped event before calling each listener, not afterwards. + if ($event instanceof StoppableEventInterface) { + if ($event->isPropagationStopped()) { + return $event; + } + } + + call_user_func($callback, $event); + } + + // Developers need to be careful to not create infinite loops in hook callbacks. + return $event; + } + + /** + * Initialise list of all callbacks for each hook. + * + * @return void + */ + private function init_standard_callbacks(): void { + global $CFG; + + $this->allcallbacks = []; + $this->alldeprecations = []; + + $cache = null; + // @codeCoverageIgnoreStart + if (!PHPUNIT_TEST && !CACHE_DISABLE_ALL) { + $cache = \cache::make('core', 'hookcallbacks'); + $callbacks = $cache->get('callbacks'); + $deprecations = $cache->get('deprecations'); + if (is_array($callbacks) && is_array($deprecations)) { + $this->allcallbacks = $callbacks; + $this->alldeprecations = $deprecations; + return; + } + } + // @codeCoverageIgnoreEnd + + // Get list of all files with callbacks, one per component. + $components = ['core' => "{$CFG->dirroot}/lib/db/hooks.php"]; + $plugintypes = \core_component::get_plugin_types(); + foreach ($plugintypes as $plugintype => $plugintypedir) { + $plugins = \core_component::get_plugin_list($plugintype); + foreach ($plugins as $pluginname => $plugindir) { + if (!$plugindir) { + continue; + } + + $components["{$plugintype}_{$pluginname}"] = "{$plugindir}/db/hooks.php"; + } + } + + // Load the callbacks and apply overrides. + $this->load_callbacks($components); + + if ($cache) { + $cache->set('callbacks', $this->allcallbacks); + $cache->set('deprecations', $this->alldeprecations); + } + } + + /** + * Load callbacks from component db/hooks.php files. + * + * @param array $componentfiles list of all components with their callback files + * @return void + */ + private function load_callbacks(array $componentfiles): void { + $this->allcallbacks = []; + $this->alldeprecations = []; + + array_map( + [$this, 'add_component_callbacks'], + array_keys($componentfiles), + $componentfiles, + ); + $this->load_callback_overrides(); + $this->prioritise_callbacks(); + $this->fetch_deprecated_callbacks(); + } + + /** + * In extremely special cases admins may decide to override callbacks via config.php setting. + */ + private function load_callback_overrides(): void { + global $CFG; + + if (!property_exists($CFG, 'hooks_callback_overrides')) { + return; + } + + if (!is_iterable($CFG->hooks_callback_overrides)) { + debugging('hooks_callback_overrides must be an array', DEBUG_DEVELOPER); + return; + } + + foreach ($CFG->hooks_callback_overrides as $hookclassname => $overrides) { + if (!is_iterable($overrides)) { + debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER); + continue; + } + + if (!array_key_exists($hookclassname, $this->allcallbacks)) { + debugging('hooks_callback_overrides must be an array of arrays with existing hook classnames', DEBUG_DEVELOPER); + continue; + } + + foreach ($overrides as $callback => $override) { + if (!is_array($override)) { + debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER); + continue; + } + + $found = false; + foreach ($this->allcallbacks[$hookclassname] as $index => $definition) { + if ($definition['callback'] === $callback) { + if (isset($override['priority'])) { + $definition['defaultpriority'] = $definition['priority']; + $definition['priority'] = (int) $override['priority']; + } + + if (!empty($override['disabled'])) { + $definition['disabled'] = true; + } + + $this->allcallbacks[$hookclassname][$index] = $definition; + $found = true; + break; + } + } + if (!$found) { + debugging("Unable to find callback '{$callback}' for '{$hookclassname}'", DEBUG_DEVELOPER); + } + } + } + } + + /** + * Prioritise the callbacks. + */ + private function prioritise_callbacks(): void { + // Prioritise callbacks. + foreach ($this->allcallbacks as $hookclassname => $hookcallbacks) { + \core_collator::asort_array_of_arrays_by_key($hookcallbacks, 'priority', \core_collator::SORT_NUMERIC); + $hookcallbacks = array_reverse($hookcallbacks); + $this->allcallbacks[$hookclassname] = $hookcallbacks; + } + } + + /** + * Fetch the list of callbacks that this hook replaces. + */ + private function fetch_deprecated_callbacks(): void { + $candidates = self::discover_known_hooks(); + + /** @var class-string $hookclassname */ + foreach (array_keys($candidates) as $hookclassname) { + if (!class_exists($hookclassname)) { + continue; + } + // It's 2023 and PHP still doesn't provide a simple way to detect if a class implements an interface without + // that class being instantiated. + $rc = new \ReflectionClass($hookclassname); + if (!$rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) { + continue; + } + $deprecations = $hookclassname::get_deprecated_plugin_callbacks(); + if (!$deprecations) { + continue; + } + foreach ($deprecations as $deprecation) { + $this->alldeprecations[$deprecation][] = $hookclassname; + } + } + } + + /** + * Add hook callbacks from file. + * + * @param string $component component where hook callbacks are defined + * @param string $hookfile file with list of all callbacks for component + * @return void + */ + private function add_component_callbacks(string $component, string $hookfile): void { + if (!file_exists($hookfile)) { + return; + } + + $parsecallbacks = function($hookfile) { + $callbacks = []; + include($hookfile); + return $callbacks; + }; + + $callbacks = $parsecallbacks($hookfile); + + if (!is_array($callbacks) || !$callbacks) { + return; + } + + foreach ($callbacks as $callbackdata) { + if (empty($callbackdata['hook'])) { + debugging("Hook callback definition requires 'hook' name in '$component'", DEBUG_DEVELOPER); + continue; + } + + $callbackmethod = $this->normalise_callback($component, $callbackdata); + if ($callbackmethod === null) { + continue; + } + + $callback = [ + 'callback' => $callbackmethod, + 'component' => $component, + 'disabled' => false, + 'priority' => 100, + ]; + + if (isset($callbackdata['priority'])) { + $callback['priority'] = (int) $callbackdata['priority']; + } + + $hook = ltrim($callbackdata['hook'], '\\'); // Normalise hook class name. + $this->allcallbacks[$hook][] = $callback; + } + } + + /** + * Normalise the callback class::method value. + * + * @param string $component + * @param array $callback + * @return null|string + */ + private function normalise_callback(string $component, array $callback): ?string { + if (empty($callback['callback'])) { + debugging("Hook callback definition requires 'callback' callable in '$component'", DEBUG_DEVELOPER); + return null; + } + $classmethod = $callback['callback']; + if (!is_string($classmethod)) { + debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER); + return null; + } + if (!str_contains($classmethod, '::')) { + debugging( + "Hook callback definition contains invalid 'callback' static class method string in '$component'", + DEBUG_DEVELOPER + ); + return null; + } + + // Normalise the callback class::method name, we use it later as an identifier. + $classmethod = ltrim($classmethod, '\\'); + + return $classmethod; + } + + /** + * Is the plugin callback from lib.php deprecated by any hook? + * + * @param string $plugincallback short callback name without the component prefix + * @return bool + */ + public function is_deprecated_plugin_callback(string $plugincallback): bool { + return isset($this->alldeprecations[$plugincallback]); + } + + /** + * Is there a hook callback in component that deprecates given lib.php plugin callback? + * + * NOTE: if there is both hook and deprecated callback then we ignore the old callback + * to allow compatibility of contrib plugins with multiple Moodle branches. + * + * @param string $component + * @param string $plugincallback short callback name without the component prefix + * @return bool + */ + public function is_deprecating_hook_present(string $component, string $plugincallback): bool { + if (!isset($this->alldeprecations[$plugincallback])) { + return false; + } + + foreach ($this->alldeprecations[$plugincallback] as $hookclassname) { + if (!isset($this->allcallbacks[$hookclassname])) { + continue; + } + foreach ($this->allcallbacks[$hookclassname] as $definition) { + if ($definition['component'] === $component) { + return true; + } + } + } + + return false; + } + + /** + * Returns list of hooks discovered through standardised Moodle methods. + * + * Note that the exact discovery logic may change in the future, + * for now this looks for hooks mentioned in callback registrations + * and non-abstract classes in \component_name\hook namespaces that + * implement described_hook interface. + * + * @return array hook class names + */ + public static function discover_known_hooks(): array { + $hooks = \core\hooks::discover_hooks(); + + foreach (\core_component::get_component_names() as $component) { + $classname = "{$component}\\hooks"; + + if (!class_exists($classname)) { + continue; + } + + $rc = new \ReflectionClass($classname); + if (!$rc->implementsInterface(\core\hook\hook_discover_agent::class)) { + continue; + } + + $hooks = array_merge($hooks, $classname::discover_hooks()); + } + + return $hooks; + } +} diff --git a/lib/classes/hooks.php b/lib/classes/hooks.php new file mode 100644 index 00000000000..5edb4846fa9 --- /dev/null +++ b/lib/classes/hooks.php @@ -0,0 +1,67 @@ +. + +namespace core; + +/** + * Hook discovery agent for core. + * + * @package core + * @copyright Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hooks implements \core\hook\discovery_agent { + public static function discover_hooks(): array { + // Describe any hard-coded hooks which can't be easily discovered by namespace. + $hooks = []; + + $hooks = array_merge($hooks, self::discover_hooks_in_namespace('core', 'hook')); + + return $hooks; + } + + public static function discover_hooks_in_namespace(string $component, string $namespace): array { + $classes = \core_component::get_component_classes_in_namespace($component, $namespace); + + $hooks = []; + foreach (array_keys($classes) as $classname) { + $rc = new \ReflectionClass($classname); + if ($rc->isAbstract()) { + // Skip abstract classes. + continue; + } + + if (is_a($classname, \core\hook\manager::class, true)) { + // Skip the manager. + continue; + } + + $hooks[$classname] = [ + 'class' => $classname, + 'description' => '', + 'tags' => [], + ]; + + if ($rc->implementsInterface(\core\hook\described_hook::class)) { + $hooks[$classname]['description'] = $classname::get_hook_description(); + } + + + } + + return $hooks; + } +} diff --git a/lib/db/caches.php b/lib/db/caches.php index 8d0c2be2d74..e4c67e08ca2 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -77,6 +77,19 @@ $definitions = array( 'simpledata' => true, ), + // Hook callbacks cache. + // There is a static cache in hook manager, data is fetched once per page on first hook execution. + // This cache needs to be invalidated during upgrades when code changes and when callbacks + // overrides are updated. + 'hookcallbacks' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => false, + // WARNING: Manual cache purge may be required when overriding hook callbacks. + 'canuselocalstore' => true, + ), + // Cache for question definitions. This is used by the question_bank class. // Users probably do not need to know about this cache. They will just call // question_bank::load_question. diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 7f98a71e89d..a90318c1f11 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -7861,9 +7861,10 @@ function get_plugin_list_with_function($plugintype, $function, $file = 'lib.php' * @param string $file the name of file within the plugin that defines the * function. Defaults to lib.php. * @param bool $include Whether to include the files that contain the functions or not. + * @param bool $migratedtohook if true this is a deprecated lib.php callback, if hook callback is present then do nothing * @return array with [plugintype][plugin] = functionname */ -function get_plugins_with_function($function, $file = 'lib.php', $include = true) { +function get_plugins_with_function($function, $file = 'lib.php', $include = true, bool $migratedtohook = false) { global $CFG; if (during_initial_install() || isset($CFG->upgraderunning)) { @@ -7871,6 +7872,25 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true return []; } + $plugincallback = $function; + $filtermigrated = function($plugincallback, $pluginfunctions): array { + foreach ($pluginfunctions as $plugintype => $plugins) { + foreach ($plugins as $plugin => $unusedfunction) { + $component = $plugintype . '_' . $plugin; + if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($plugincallback)) { + if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $plugincallback)) { + // Ignore the old callback, it is there only for older Moodle versions. + unset($pluginfunctions[$plugintype][$plugin]); + } else { + debugging("Callback $plugincallback in $component component should be migrated to new hook callback", + DEBUG_DEVELOPER); + } + } + } + } + return $pluginfunctions; + }; + $cache = \cache::make('core', 'plugin_functions'); // Including both although I doubt that we will find two functions definitions with the same name. @@ -7925,6 +7945,9 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true // If the cache is dirty, we should fall through and let it rebuild. if (!$dirty) { + if ($migratedtohook && $file === 'lib.php') { + $pluginfunctions = $filtermigrated($plugincallback, $pluginfunctions); + } return $pluginfunctions; } } @@ -7972,6 +7995,10 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true $cache->set($key, $pluginfunctions); } + if ($migratedtohook && $file === 'lib.php') { + $pluginfunctions = $filtermigrated($plugincallback, $pluginfunctions); + } + return $pluginfunctions; } @@ -8061,12 +8088,13 @@ function get_list_of_plugins($directory='mod', $exclude='', $basedir='') { * @param string $action feature's action * @param array $params parameters of callback function, should be an array * @param mixed $default default value if callback function hasn't been defined, or if it retursn null. + * @param bool $migratedtohook if true this is a deprecated callback, if hook callback is present then do nothing * @return mixed * * @todo Decide about to deprecate and drop plugin_callback() - MDL-30743 */ -function plugin_callback($type, $name, $feature, $action, $params = null, $default = null) { - return component_callback($type . '_' . $name, $feature . '_' . $action, (array) $params, $default); +function plugin_callback($type, $name, $feature, $action, $params = null, $default = null, bool $migratedtohook = false) { + return component_callback($type . '_' . $name, $feature . '_' . $action, (array) $params, $default, $migratedtohook); } /** @@ -8076,9 +8104,10 @@ function plugin_callback($type, $name, $feature, $action, $params = null, $defau * @param string $function the rest of the function name, e.g. 'cron' will end up calling 'mod_quiz_cron' * @param array $params parameters of callback function * @param mixed $default default value if callback function hasn't been defined, or if it retursn null. + * @param bool $migratedtohook if true this is a deprecated callback, if hook callback is present then do nothing * @return mixed */ -function component_callback($component, $function, array $params = array(), $default = null) { +function component_callback($component, $function, array $params = array(), $default = null, bool $migratedtohook = false) { $functionname = component_callback_exists($component, $function); @@ -8093,6 +8122,19 @@ function component_callback($component, $function, array $params = array(), $def } if ($functionname) { + if ($migratedtohook) { + if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($function)) { + if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $function)) { + // Do not call the old lib.php callback, + // it is there for compatibility with older Moodle versions only. + return null; + } else { + debugging("Callback $function in $component component should be migrated to new hook callback", + DEBUG_DEVELOPER); + } + } + } + // Function exists, so just return function result. $ret = call_user_func_array($functionname, $params); if (is_null($ret)) { diff --git a/lib/phpunit/bootstrap.php b/lib/phpunit/bootstrap.php index e8be0752c2d..dd15d1bb189 100644 --- a/lib/phpunit/bootstrap.php +++ b/lib/phpunit/bootstrap.php @@ -223,6 +223,10 @@ if (PHPUNIT_UTIL) { return; } +// Make sure the hook manager gets initialised before anybody tries to override callbacks, +// this is not using caches intentionally to help with development. +\core\hook\manager::get_instance(); + // is database and dataroot ready for testing? list($errorcode, $message) = phpunit_util::testing_ready_problem(); // print some version info diff --git a/lib/psr/event-dispatcher/LICENSE b/lib/psr/event-dispatcher/LICENSE new file mode 100644 index 00000000000..3f1559b2ad6 --- /dev/null +++ b/lib/psr/event-dispatcher/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 PHP-FIG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/psr/event-dispatcher/README.md b/lib/psr/event-dispatcher/README.md new file mode 100644 index 00000000000..01a8c1aaf1c --- /dev/null +++ b/lib/psr/event-dispatcher/README.md @@ -0,0 +1,12 @@ +Event Dispatcher +============== + +This repository holds all interfaces related to [PSR-14 (Event Dispatcher)][psr-url]. + +Note that this is not an Event Dispatcher implementation of its own. It is merely interfaces that describe the components of an Event Dispatcher. + +You can find [implementations][implementation-url] and [installation instructions][package-url] for the specification on the packagist. + +[psr-url]: https://www.php-fig.org/psr/psr-14/ +[package-url]: https://packagist.org/packages/psr/event-dispatcher +[implementation-url]: https://packagist.org/providers/psr/event-dispatcher-implementation diff --git a/lib/psr/event-dispatcher/composer.json b/lib/psr/event-dispatcher/composer.json new file mode 100644 index 00000000000..adc828572b3 --- /dev/null +++ b/lib/psr/event-dispatcher/composer.json @@ -0,0 +1,29 @@ +{ + "name": "psr/event-dispatcher", + "description": "Standard interfaces for event handling.", + "type": "library", + "keywords": ["psr", "psr-14", "events"], + "license": "MIT", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "require": { + "php": ">=7.2.0" + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "suggest": { + "fig/event-dispatcher-util": "Provides some useful PSR-14 utilities" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + } +} diff --git a/lib/psr/event-dispatcher/src/EventDispatcherInterface.php b/lib/psr/event-dispatcher/src/EventDispatcherInterface.php new file mode 100644 index 00000000000..4306fa91560 --- /dev/null +++ b/lib/psr/event-dispatcher/src/EventDispatcherInterface.php @@ -0,0 +1,21 @@ + + * An iterable (array, iterator, or generator) of callables. Each + * callable MUST be type-compatible with $event. + */ + public function getListenersForEvent(object $event) : iterable; +} diff --git a/lib/psr/event-dispatcher/src/StoppableEventInterface.php b/lib/psr/event-dispatcher/src/StoppableEventInterface.php new file mode 100644 index 00000000000..59550816467 --- /dev/null +++ b/lib/psr/event-dispatcher/src/StoppableEventInterface.php @@ -0,0 +1,26 @@ +. + +namespace test_plugin; + +/** + * Fixture for testing of hooks. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class callbacks { + /** @var string[] list of calls */ + public static $calls = []; + + /** + * Callback tester. + * + * @param \test_plugin\hook\hook $hook + * @return void + */ + public static function test1(\test_plugin\hook\hook $hook): void { + self::$calls[] = 'test1'; + } + + /** + * Callback tester. + * + * @param \test_plugin\hook\hook $hook + * @return void + */ + public static function test2(\test_plugin\hook\hook $hook): void { + self::$calls[] = 'test2'; + } + + /** + * Callback tester. + * + * @param \test_plugin\hook\stoppablehook $hook + * @return void + */ + public static function stop1(\test_plugin\hook\stoppablehook $hook): void { + self::$calls[] = 'stop1'; + $hook->stop(); + } + + /** + * Callback tester. + * + * @param \test_plugin\hook\stoppablehook $hook + * @return void + */ + public static function stop2(\test_plugin\hook\stoppablehook $hook): void { + self::$calls[] = 'stop2'; + $hook->stop(); + } + + /** + * Callback tester for exceptions. + * + * @param \test_plugin\hook\hook $hook + * @return void + */ + public static function exception(\test_plugin\hook\hook $hook): void { + self::$calls[] = 'exception'; + throw new \Exception('grrr'); + } +} diff --git a/lib/tests/fixtures/hook/hook.php b/lib/tests/fixtures/hook/hook.php new file mode 100644 index 00000000000..0003aef41ae --- /dev/null +++ b/lib/tests/fixtures/hook/hook.php @@ -0,0 +1,44 @@ +. + +namespace test_plugin\hook; + +/** + * Fixture for testing of hooks. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class hook implements + \core\hook\described_hook, + \core\hook\deprecated_callback_replacement { + + /** + * Hook description. + */ + public static function get_hook_description(): string { + return 'Test hook 1.'; + } + + /** + * Deprecation info. + */ + public static function get_deprecated_plugin_callbacks(): array { + return ['oldcallback']; + } +} diff --git a/lib/tests/fixtures/hook/hooks1_broken.php b/lib/tests/fixtures/hook/hooks1_broken.php new file mode 100644 index 00000000000..1cfe896c3f3 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks1_broken.php @@ -0,0 +1,42 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'callback' => 'test_plugin\\callbacks::test2', + 'priority' => 100, + ], + [ + 'hook' => 'test_plugin\\hook\\hook', + 'priority' => 100, + ], + [ + 'hook' => 'test_plugin\\hook\\hook', + 'callback' => 'test_plugin\\callbackstest2', + 'priority' => 100, + ], +]; diff --git a/lib/tests/fixtures/hook/hooks1_exception.php b/lib/tests/fixtures/hook/hooks1_exception.php new file mode 100644 index 00000000000..8048e6a3df1 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks1_exception.php @@ -0,0 +1,34 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\hook', + 'callback' => 'test_plugin\\callbacks::exception', + 'priority' => 1000, + ], +]; diff --git a/lib/tests/fixtures/hook/hooks1_missing.php b/lib/tests/fixtures/hook/hooks1_missing.php new file mode 100644 index 00000000000..095438946ea --- /dev/null +++ b/lib/tests/fixtures/hook/hooks1_missing.php @@ -0,0 +1,34 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\hook', + 'callback' => 'test_plugin\\callbacks::missing', + 'priority' => 1000, + ], +]; diff --git a/lib/tests/fixtures/hook/hooks1_stoppable.php b/lib/tests/fixtures/hook/hooks1_stoppable.php new file mode 100644 index 00000000000..ec22664fa43 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks1_stoppable.php @@ -0,0 +1,34 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\stoppablehook', + 'callback' => 'test_plugin\\callbacks::stop1', + 'priority' => 400, + ], +]; diff --git a/lib/tests/fixtures/hook/hooks1_valid.php b/lib/tests/fixtures/hook/hooks1_valid.php new file mode 100644 index 00000000000..78266ac4890 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks1_valid.php @@ -0,0 +1,33 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\hook', + 'callback' => 'test_plugin\\callbacks::test1', + ], +]; diff --git a/lib/tests/fixtures/hook/hooks2_stoppable.php b/lib/tests/fixtures/hook/hooks2_stoppable.php new file mode 100644 index 00000000000..0d6a8166f91 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks2_stoppable.php @@ -0,0 +1,34 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\stoppablehook', + 'callback' => 'test_plugin\\callbacks::stop2', + 'priority' => 200, + ], +]; diff --git a/lib/tests/fixtures/hook/hooks2_valid.php b/lib/tests/fixtures/hook/hooks2_valid.php new file mode 100644 index 00000000000..3880ec008d2 --- /dev/null +++ b/lib/tests/fixtures/hook/hooks2_valid.php @@ -0,0 +1,34 @@ +. + +/** + * Fixtures for hook testing. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'test_plugin\\hook\\hook', + 'callback' => 'test_plugin\\callbacks::test2', + 'priority' => 200, + ], +]; diff --git a/lib/tests/fixtures/hook/stoppablehook.php b/lib/tests/fixtures/hook/stoppablehook.php new file mode 100644 index 00000000000..938b7787c3a --- /dev/null +++ b/lib/tests/fixtures/hook/stoppablehook.php @@ -0,0 +1,56 @@ +. + +namespace test_plugin\hook; + +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Fixture for testing of hooks. + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class stoppablehook implements + StoppableEventInterface, + \core\hook\described_hook { + + /** @var bool stoppable flag */ + private $stopped = false; + + /** + * Hook description. + */ + public static function get_hook_description(): string { + return 'Test hook 2.'; + } + + /** + * Stop other callbacks. + */ + public function stop(): void { + $this->stopped = true; + } + + /** + * Indicates if callback propagation should stop. + */ + public function isPropagationStopped(): bool { + return $this->stopped; + } +} diff --git a/lib/tests/hook/manager_test.php b/lib/tests/hook/manager_test.php new file mode 100644 index 00000000000..67949e3d1ff --- /dev/null +++ b/lib/tests/hook/manager_test.php @@ -0,0 +1,353 @@ +. + +namespace core\hook; + +/** + * Hooks tests. + * + * @coversDefaultClass \core\hook\manager + * + * @package core + * @author Petr Skoda + * @copyright 2022 Open LMS + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager_test extends \advanced_testcase { + /** + * Test public factory method to get hook manager. + * @covers ::get_instance + */ + public function test_get_instance() { + $manager = manager::get_instance(); + $this->assertInstanceOf(manager::class, $manager); + + $this->assertSame($manager, manager::get_instance()); + } + + /** + * Test getting of manager test instance. + * @covers ::phpunit_get_instance + */ + public function test_phpunit_get_instance() { + $testmanager = manager::phpunit_get_instance([]); + $this->assertSame([], $testmanager->get_hooks_with_callbacks()); + + // We get a new instance every time. + $this->assertNotSame($testmanager, manager::phpunit_get_instance([])); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + } + + /** + * Test reset of test instance. + * + * NOTE: normal hook manger instance cannot be reset in PHPUnit test + * because it may be used to control the test environment itself. + * + * @covers ::reset_caches + * @covers ::init_standard_callbacks + */ + public function test_reset_caches() { + $testmanager = manager::phpunit_get_instance([]); + $this->assertSame([], $testmanager->get_hooks_with_callbacks()); + + $testmanager->reset_caches(); + $manager = manager::get_instance(); + $this->assertSame($manager->get_hooks_with_callbacks(), $testmanager->get_hooks_with_callbacks()); + } + + /** + * Test loading and parsing of callbacks from files. + * + * @covers ::get_callbacks_for_hook + * @covers ::get_hooks_with_callbacks + * @covers ::load_callbacks + * @covers ::add_component_callbacks + */ + public function test_callbacks() { + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); + $this->assertCount(2, $callbacks); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test2', + 'component' => 'test_plugin2', + 'disabled' => false, + 'priority' => 200, + ], $callbacks[0]); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test1', + 'component' => 'test_plugin1', + 'disabled' => false, + 'priority' => 100, + ], $callbacks[1]); + + $this->assertDebuggingNotCalled(); + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_broken.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame([], $testmanager->get_hooks_with_callbacks()); + $debuggings = $this->getDebuggingMessages(); + $this->resetDebugging(); + $this->assertSame('Hook callback definition requires \'hook\' name in \'test_plugin1\'', + $debuggings[0]->message); + $this->assertSame('Hook callback definition requires \'callback\' callable in \'test_plugin1\'', + $debuggings[1]->message); + $this->assertSame('Hook callback definition contains invalid \'callback\' static class method string in \'test_plugin1\'', + $debuggings[2]->message); + $this->assertCount(3, $debuggings); + } + + /** + * Test hook dispatching, that is callback execution. + * @covers ::dispatch + */ + public function test_dispatch(): void { + require_once(__DIR__ . '/../fixtures/hook/hook.php'); + require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + \test_plugin\callbacks::$calls = []; + $hook = new \test_plugin\hook\hook(); + $result = $testmanager->dispatch($hook); + $this->assertSame($hook, $result); + $this->assertSame(['test2', 'test1'], \test_plugin\callbacks::$calls); + \test_plugin\callbacks::$calls = []; + $this->assertDebuggingNotCalled(); + } + + /** + * Test hook dispatching, that is callback execution. + * @covers ::dispatch + */ + public function test_dispatch_with_exception(): void { + require_once(__DIR__ . '/../fixtures/hook/hook.php'); + require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_exception.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + + $hook = new \test_plugin\hook\hook(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('grrr'); + + $testmanager->dispatch($hook); + } + + /** + * Test hook dispatching, that is callback execution. + * @covers ::dispatch + */ + public function test_dispatch_with_invalid(): void { + // Missing callbacks is ignored. + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_missing.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + \test_plugin\callbacks::$calls = []; + + $hook = new \test_plugin\hook\hook(); + + $testmanager->dispatch($hook); + $this->assertDebuggingCalled( + "Hook callback definition contains invalid 'callback' method name in 'test_plugin1'. Callback method not found.", + ); + $this->assertSame(['test2'], \test_plugin\callbacks::$calls); + } + + /** + * Test stoppping of hook dispatching. + * @covers ::dispatch + */ + public function test_dispatch_stoppable() { + require_once(__DIR__ . '/../fixtures/hook/stoppablehook.php'); + require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_stoppable.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_stoppable.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + \test_plugin\callbacks::$calls = []; + $hook = new \test_plugin\hook\stoppablehook(); + $result = $testmanager->dispatch($hook); + $this->assertSame($hook, $result); + $this->assertSame(['stop1'], \test_plugin\callbacks::$calls); + \test_plugin\callbacks::$calls = []; + $this->assertDebuggingNotCalled(); + } + + /** + * Test deprecated callback lookup. + * @covers ::is_deprecated_plugin_callback + */ + public function testy_is_deprecated_plugin_callback() { + require_once(__DIR__ . '/../fixtures/hook/hook.php'); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertTrue($testmanager->is_deprecated_plugin_callback('oldcallback')); + $this->assertFalse($testmanager->is_deprecated_plugin_callback('legacycallback')); + } + + /** + * Tests callbacks can be overridden via CFG settings. + * @covers ::load_callbacks + * @covers ::dispatch + */ + public function test_callback_overriding() { + global $CFG; + $this->resetAfterTest(); + + $componentfiles = [ + 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', + 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', + ]; + + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); + $this->assertCount(2, $callbacks); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test2', + 'component' => 'test_plugin2', + 'disabled' => false, + 'priority' => 200, + ], $callbacks[0]); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test1', + 'component' => 'test_plugin1', + 'disabled' => false, + 'priority' => 100, + ], $callbacks[1]); + + $CFG->hooks_callback_overrides = [ + 'test_plugin\\hook\\hook' => [ + 'test_plugin\\callbacks::test2' => ['priority' => 33] + ] + ]; + + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); + $this->assertCount(2, $callbacks); + $this->normalise_callbacks($callbacks); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test1', + 'component' => 'test_plugin1', + 'disabled' => false, + 'priority' => 100, + ], $callbacks[0]); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test2', + 'component' => 'test_plugin2', + 'defaultpriority' => 200, + 'disabled' => false, + 'priority' => 33, + ], $callbacks[1]); + + $CFG->hooks_callback_overrides = [ + 'test_plugin\\hook\\hook' => [ + 'test_plugin\\callbacks::test2' => ['priority' => 33, 'disabled' => true] + ] + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); + $this->assertCount(2, $callbacks); + $this->normalise_callbacks($callbacks); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test1', + 'component' => 'test_plugin1', + 'disabled' => false, + 'priority' => 100, + ], + $callbacks[0]); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test2', + 'component' => 'test_plugin2', + 'defaultpriority' => 200, + 'disabled' => true, + 'priority' => 33, + ], $callbacks[1]); + + $CFG->hooks_callback_overrides = [ + 'test_plugin\\hook\\hook' => [ + 'test_plugin\\callbacks::test2' => ['disabled' => true], + ] + ]; + $testmanager = manager::phpunit_get_instance($componentfiles); + $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); + $this->assertCount(2, $callbacks); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test2', + 'component' => 'test_plugin2', + 'disabled' => true, + 'priority' => 200, + ], $callbacks[0]); + $this->assertSame([ + 'callback' => 'test_plugin\\callbacks::test1', + 'component' => 'test_plugin1', + 'disabled' => false, + 'priority' => 100, + ], $callbacks[1]); + + require_once(__DIR__ . '/../fixtures/hook/hook.php'); + require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); + + \test_plugin\callbacks::$calls = []; + $hook = new \test_plugin\hook\hook(); + $result = $testmanager->dispatch($hook); + $this->assertSame($hook, $result); + $this->assertSame(['test1'], \test_plugin\callbacks::$calls); + \test_plugin\callbacks::$calls = []; + $this->assertDebuggingNotCalled(); + } + + /** + * Normalise the sort order of callbacks to help with asserts. + * + * @param array $callbacks + * @return void + */ + private function normalise_callbacks(array &$callbacks): void { + foreach ($callbacks as &$callback) { + ksort($callback); + } + } +} diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index 14e5eaf0865..625bcbf251d 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -629,6 +629,14 @@ All rights reserved. MIT https://github.com/php-fig/http-message + + psr/event-dispatcher + event-dispatcher + Provides interfaces that descirbe an event dispatching mechanism. + 1.0.0 + MIT + https://github.com/php-fig/event-dispatcher + phpxmlrpc phpxmlrpc