From ff1dcfde58a81458aa5c39df3386c08743a076a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0koda?= Date: Tue, 18 Feb 2014 10:52:23 +0800 Subject: [PATCH] MDL-44078 add support for hooks and hook callbacks --- lib/classes/hook/base.php | 52 ++++++++++ lib/classes/hook/manager.php | 230 +++++++++++++++++++++++++++++++++++++++++++ lib/db/caches.php | 9 ++ lib/phpunit/classes/util.php | 1 + lib/tests/fixtures/hook.php | 28 ++++++ lib/tests/hook_test.php | 32 ++++++ 6 files changed, 352 insertions(+) create mode 100644 lib/classes/hook/base.php create mode 100644 lib/classes/hook/manager.php create mode 100644 lib/tests/fixtures/hook.php create mode 100644 lib/tests/hook_test.php diff --git a/lib/classes/hook/base.php b/lib/classes/hook/base.php new file mode 100644 index 0000000..658b8b3 --- /dev/null +++ b/lib/classes/hook/base.php @@ -0,0 +1,52 @@ +. + +namespace core\hook; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Base hook class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * All other hook classes must extend this class. + * + * @package core + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + /** @var bool $executing Is the hook being executed? */ + protected $executing = false; + + /** + * Execute the callbacks. + * + * @return base $this + */ + public function execute() { + if ($this->executing) { + debugging('hook is already being executed', DEBUG_DEVELOPER); + return $this; + } + return manager::execute($this); + } +} diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php new file mode 100644 index 0000000..f9b716b --- /dev/null +++ b/lib/classes/hook/manager.php @@ -0,0 +1,230 @@ +. + +namespace core\hook; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Hook manager class. + * + * @package core + * @copyright 2014 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Manager executing hook callbacks. + * + * @package core + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class manager { + /** @var array cache of all callbacks */ + protected static $allcallbacks = null; + + /** @var bool should we reload callbacks after the test? */ + protected static $reloadaftertest = false; + + /** + * Execute all hook callbacks. + * + * @param base $hook + * @return base returns the hook instance to allow chaining + */ + public static function execute(base $hook) { + global $CFG; + + if (during_initial_install()) { + return $hook; + } + self::init_all_callbacks(); + + $hookname = '\\' . get_class($hook); + if (!isset(self::$allcallbacks[$hookname])) { + return $hook; + } + + foreach (self::$allcallbacks[$hookname] as $callback) { + if (isset($callback->includefile) and file_exists($callback->includefile)) { + include_once($callback->includefile); + } + if (is_callable($callback->callable)) { + try { + call_user_func($callback->callable, $hook); + } catch (\Exception $e) { + // Callbacks are executed before installation and upgrade, this may throw errors. + if (empty($CFG->upgraderunning)) { + // Ignore errors during upgrade, otherwise warn developers. + debugging("Exception encountered in hook callback '$callback->callable': " . + $e->getMessage(), DEBUG_DEVELOPER, $e->getTrace()); + } + } + } else { + debugging("Cannot execute hook callback '$callback->callable'"); + } + } + + // Note: there is no protection against infinite recursion, sorry. + + return $hook; + } + + /** + * Initialise the list of callbacks. + */ + protected static function init_all_callbacks() { + global $CFG; + + if (is_array(self::$allcallbacks)) { + return; + } + + if (!PHPUNIT_TEST and !during_initial_install()) { + $cache = \cache::make('core', 'hookcallbacks'); + $cached = $cache->get('all'); + $dirroot = $cache->get('dirroot'); + if ($dirroot === $CFG->dirroot and is_array($cached)) { + self::$allcallbacks = $cached; + return; + } + } + + self::$allcallbacks = array(); + + $plugintypes = \core_component::get_plugin_types(); + $systemdone = false; + foreach ($plugintypes as $plugintype => $ignored) { + $plugins = \core_component::get_plugin_list($plugintype); + if (!$systemdone) { + $plugins[] = "$CFG->dirroot/lib"; + $systemdone = true; + } + + foreach ($plugins as $fulldir) { + if (!file_exists("$fulldir/db/hooks.php")) { + continue; + } + $callbacks = null; + include("$fulldir/db/hooks.php"); + if (!is_array($callbacks)) { + continue; + } + self::add_callbacks($callbacks, "$fulldir/db/hooks.php"); + } + } + + self::order_all_callbacks(); + + if (!PHPUNIT_TEST and !during_initial_install()) { + $cache->set('all', self::$allcallbacks); + $cache->set('dirroot', $CFG->dirroot); + } + } + + /** + * Add callbacks. + * @param array $callbacks + * @param string $file + */ + protected static function add_callbacks(array $callbacks, $file) { + global $CFG; + + foreach ($callbacks as $callback) { + if (empty($callback['hookname']) or !is_string($callback['hookname'])) { + debugging("Invalid 'hookname' detected in $file callback definition", DEBUG_DEVELOPER); + continue; + } + if (strpos($callback['hookname'], '\\') !== 0) { + $callback['hookname'] = '\\' . $callback['hookname']; + } + if (empty($callback['callback'])) { + debugging("Invalid 'callback' detected in $file callback definition", DEBUG_DEVELOPER); + continue; + } + $o = new \stdClass(); + $o->callable = $callback['callback']; + if (!isset($callback['priority'])) { + $o->priority = 0; + } else { + $o->priority = (int)$callback['priority']; + } + if (empty($callback['includefile'])) { + $o->includefile = null; + } else { + if ($CFG->admin !== 'admin' and strpos($callback['includefile'], '/admin/') === 0) { + $callback['includefile'] = preg_replace('|^/admin/|', '/' . $CFG->admin . '/', $callback['includefile']); + } + $callback['includefile'] = $CFG->dirroot . '/' . ltrim($callback['includefile'], '/'); + if (!file_exists($callback['includefile'])) { + debugging("Invalid 'includefile' detected in $file callback definition", DEBUG_DEVELOPER); + continue; + } + $o->includefile = $callback['includefile']; + } + self::$allcallbacks[$callback['hookname']][] = $o; + } + } + + /** + * Reorder callbacks to allow quick lookup of callback for each hook. + */ + protected static function order_all_callbacks() { + foreach (self::$allcallbacks as $classname => $callbacks) { + \core_collator::asort_objects_by_property($callbacks, 'priority', \core_collator::SORT_NUMERIC); + self::$allcallbacks[$classname] = array_reverse($callbacks); + } + } + + /** + * Replace all standard callbacks. + * @param array $callbacks + * @return array + * + * @throws \coding_Exception if used outside of unit tests. + */ + public static function phpunit_replace_callbacks(array $callbacks) { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Cannot override hook callbacks outside of phpunit tests!'); + } + + self::phpunit_reset(); + self::$allcallbacks = array(); + self::$reloadaftertest = true; + + self::add_callbacks($callbacks, 'phpunit'); + self::order_all_callbacks(); + + return self::$allcallbacks; + } + + /** + * Reset everything if necessary. + * @private + * + * @throws \coding_Exception if used outside of unit tests. + */ + public static function phpunit_reset() { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Cannot reset hook manager outside of phpunit tests!'); + } + if (!self::$reloadaftertest) { + self::$allcallbacks = null; + } + self::$reloadaftertest = false; + } +} diff --git a/lib/db/caches.php b/lib/db/caches.php index c882073..ffbb084 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -141,6 +141,15 @@ $definitions = array( 'staticaccelerationsize' => 2, ), + // Cache for the list of hook callbacks. + 'hookcallbacks' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true, + 'staticaccelerationsize' => 2, + ), + // Cache used by the {@link core_plugin_manager} class. // NOTE: this must be a shared cache. 'plugin_manager' => array( diff --git a/lib/phpunit/classes/util.php b/lib/phpunit/classes/util.php index c1c3b04..c3ff3e4 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -195,6 +195,7 @@ class phpunit_util extends testing_util { // reset all static caches \core\event\manager::phpunit_reset(); + \core\hook\manager::phpunit_reset(); accesslib_clear_all_caches(true); get_string_manager()->reset_caches(true); reset_text_filters_cache(true); diff --git a/lib/tests/fixtures/hook.php b/lib/tests/fixtures/hook.php new file mode 100644 index 0000000..d17baee --- /dev/null +++ b/lib/tests/fixtures/hook.php @@ -0,0 +1,28 @@ +. + +namespace core_tests\hook; + +/** + * Tests for hook manager, base class and callbacks. + * + * @package core + * @category phpunit + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); diff --git a/lib/tests/hook_test.php b/lib/tests/hook_test.php new file mode 100644 index 0000000..3639afe --- /dev/null +++ b/lib/tests/hook_test.php @@ -0,0 +1,32 @@ +. + +/** + * Tests for hook manager, base class and callbacks. + * + * @package core + * @category phpunit + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/fixtures/hook.php'); + +class core_hook_testcase extends advanced_testcase { + +} -- 1.9.0