Merge branch 'MDL-74954-master' of https://github.com/skodak/moodle

This commit is contained in:
Andrew Nicols
2023-05-22 20:03:13 +08:00
32 changed files with 2080 additions and 4 deletions
+274
View File
@@ -0,0 +1,274 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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}&nbsp;({$definition['priority']})";
if (!$iscallable) {
$info .= '&nbsp;';
$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 '';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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();
+3
View File
@@ -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
+18
View File
@@ -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';
+1
View File
@@ -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';
+1
View File
@@ -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',
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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;
}
+38
View File
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
namespace core\hook;
/**
* This interface describes a component which can discover hooks in its own namespace.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @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;
}
+586
View File
@@ -0,0 +1,586 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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<callable>
* 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<deprecated_callback_replacement> $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;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
namespace core;
/**
* Hook discovery agent for core.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @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;
}
}
+13
View File
@@ -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.
+46 -4
View File
@@ -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)) {
+4
View File
@@ -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
+21
View File
@@ -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.
+12
View File
@@ -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
+29
View File
@@ -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"
}
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Psr\EventDispatcher;
/**
* Defines a dispatcher for events.
*/
interface EventDispatcherInterface
{
/**
* Provide all relevant listeners with an event to process.
*
* @param object $event
* The object to process.
*
* @return object
* The Event that was passed, now modified by listeners.
*/
public function dispatch(object $event);
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Psr\EventDispatcher;
/**
* Mapper from an event to the listeners that are applicable to that event.
*/
interface ListenerProviderInterface
{
/**
* @param object $event
* An event for which to return the relevant listeners.
* @return iterable<callable>
* An iterable (array, iterator, or generator) of callables. Each
* callable MUST be type-compatible with $event.
*/
public function getListenersForEvent(object $event) : iterable;
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Psr\EventDispatcher;
/**
* An Event whose processing may be interrupted when the event has been handled.
*
* A Dispatcher implementation MUST check to determine if an Event
* is marked as stopped after each listener is called. If it is then it should
* return immediately without calling any further Listeners.
*/
interface StoppableEventInterface
{
/**
* Is propagation stopped?
*
* This will typically only be used by the Dispatcher to determine if the
* previous listener halted propagation.
*
* @return bool
* True if the Event is complete and no further listeners should be called.
* False to continue calling listeners.
*/
public function isPropagationStopped() : bool;
}
+83
View File
@@ -0,0 +1,83 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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'];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+33
View File
@@ -0,0 +1,33 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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',
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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,
],
];
+56
View File
@@ -0,0 +1,56 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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;
}
}
+353
View File
@@ -0,0 +1,353 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
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);
}
}
}
+8
View File
@@ -629,6 +629,14 @@ All rights reserved.</copyright>
<license>MIT</license>
<repository>https://github.com/php-fig/http-message</repository>
</library>
<library>
<location>psr/event-dispatcher</location>
<name>event-dispatcher</name>
<description>Provides interfaces that descirbe an event dispatching mechanism.</description>
<version>1.0.0</version>
<license>MIT</license>
<repository>https://github.com/php-fig/event-dispatcher</repository>
</library>
<library>
<location>phpxmlrpc</location>
<name>phpxmlrpc</name>