From 65d2e9a6297f845b3379fd9d23637ae414917822 Mon Sep 17 00:00:00 2001 From: Petr Skoda Date: Tue, 29 Dec 2015 10:36:18 +1300 Subject: [PATCH] TL-8208 add general hooks Change-Id: I5552050b7339b710599f6886d50c64e524a1f858 --- lib/phpunit/classes/util.php | 1 + totara/core/classes/hook/base.php | 48 +++++ totara/core/classes/hook/manager.php | 239 ++++++++++++++++++++++ totara/core/db/caches.php | 33 +++ totara/core/lang/en/totara_core.php | 1 + totara/core/tests/fixtures/test_hook.php | 39 ++++ totara/core/tests/fixtures/test_hook_listener.php | 46 +++++ totara/core/tests/hook_test.php | 157 ++++++++++++++ 8 files changed, 564 insertions(+) create mode 100644 totara/core/classes/hook/base.php create mode 100644 totara/core/classes/hook/manager.php create mode 100644 totara/core/db/caches.php create mode 100644 totara/core/tests/fixtures/test_hook.php create mode 100644 totara/core/tests/fixtures/test_hook_listener.php create mode 100644 totara/core/tests/hook_test.php diff --git a/lib/phpunit/classes/util.php b/lib/phpunit/classes/util.php index b5daaca..8afd8a8 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -220,6 +220,7 @@ class phpunit_util extends testing_util { core_user::reset_internal_users(); // Totara specific resets. + \totara_core\hook\manager::phpunit_reset(); if (class_exists('totara_core\jsend', false)) { \totara_core\jsend::set_phpunit_testdata(null); } diff --git a/totara/core/classes/hook/base.php b/totara/core/classes/hook/base.php new file mode 100644 index 0000000..3de3856 --- /dev/null +++ b/totara/core/classes/hook/base.php @@ -0,0 +1,48 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +namespace totara_core\hook; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Base hook class - all other hook classes must extend this class. + * + * The reason is that we want all hooks to be clearly documented + * so that developers can manage hook listeners easily. Supporting + * arbitrary classes would end up in a horrible mess. + * + * @author Petr Skoda + * @package totara_core + */ +abstract class base { + /** + * Execute the callbacks. + * + * @return self $this allows chaining + */ + public function execute() { + manager::execute($this); + return $this; + } +} diff --git a/totara/core/classes/hook/manager.php b/totara/core/classes/hook/manager.php new file mode 100644 index 0000000..efb97f6 --- /dev/null +++ b/totara/core/classes/hook/manager.php @@ -0,0 +1,239 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +namespace totara_core\hook; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Hook manager class. + * + * @author Petr Skoda + * @package totara_core + */ +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 + */ + public static function execute(base $hook) { + global $CFG; + + if (during_initial_install()) { + return; + } + self::init_all_callbacks(); + + $hookname = get_class($hook); + if (!isset(self::$allcallbacks[$hookname])) { + return; + } + + 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. + $callable = var_export($callback->callable, true); + debugging("Exception encountered in hook callback '$callable': " . + $e->getMessage(), DEBUG_DEVELOPER, $e->getTrace()); + } + } catch (\Throwable $e) { + // Callbacks are executed before installation and upgrade, this may throw errors. + if (empty($CFG->upgraderunning)) { + // Ignore errors during upgrade, otherwise warn developers. + $callable = var_export($callback->callable, true); + debugging("Error encountered in hook callback '$callable': " . + $e->getMessage(), DEBUG_DEVELOPER, $e->getTrace()); + } + } + } else { + $callable = var_export($callback->callable, true); + debugging("Cannot execute hook callback '$callable'", DEBUG_DEVELOPER); + } + } + + // Note: there is no protection against infinite recursion, sorry. + } + + /** + * 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('totara_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 structure defined in db/hooks.php + * @param string $file file name and relative path, used for debuggin only + */ + 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) { + // Normalise the class name. + $callback['hookname'] = ltrim($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 by priority to allow quick execution of callbacks for each hook class. + */ + 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. + * @private + * + * @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/totara/core/db/caches.php b/totara/core/db/caches.php new file mode 100644 index 0000000..4392906 --- /dev/null +++ b/totara/core/db/caches.php @@ -0,0 +1,33 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +$definitions = array( + // Cache for the list of hook callbacks. + 'hookcallbacks' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true, + 'staticaccelerationsize' => 2, + ), +); diff --git a/totara/core/lang/en/totara_core.php b/totara/core/lang/en/totara_core.php index 8344854..4005f58 100644 --- a/totara/core/lang/en/totara_core.php +++ b/totara/core/lang/en/totara_core.php @@ -56,6 +56,7 @@ $string['bookings'] = 'Bookings'; $string['bookingsfor'] = 'Bookings for '; $string['browse'] = 'Browse'; $string['browsecategories'] = 'Browse Categories'; +$string['cachedef_hookcallbacks'] = 'Hook callbacks'; $string['calendar'] = 'Calendar'; $string['cannotdownloadtotaralanguageupdatelist'] = 'Cannot download list of language updates from download.totaralms.com'; $string['cannotundeleteuser'] = 'Cannot undelete user'; diff --git a/totara/core/tests/fixtures/test_hook.php b/totara/core/tests/fixtures/test_hook.php new file mode 100644 index 0000000..2f585a8 --- /dev/null +++ b/totara/core/tests/fixtures/test_hook.php @@ -0,0 +1,39 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +/** + * Tests for hook manager, base class and callbacks. + * + * @author Petr Skoda + * @package totara_core + */ + +defined('MOODLE_INTERNAL') || die(); + +class totara_core_test_hook extends \totara_core\hook\base { + public $info; + + public function __construct() { + $this->info = array(); + } +} diff --git a/totara/core/tests/fixtures/test_hook_listener.php b/totara/core/tests/fixtures/test_hook_listener.php new file mode 100644 index 0000000..8523fb4 --- /dev/null +++ b/totara/core/tests/fixtures/test_hook_listener.php @@ -0,0 +1,46 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +/** + * Tests for hook manager, base class and callbacks. + * + * @author Petr Skoda + * @package totara_core + */ + +defined('MOODLE_INTERNAL') || die(); + +class totara_core_test_hook_listener { + public static function listen1(totara_core_test_hook $hook) { + $hook->info[] = 1; + } + + public static function listen2(totara_core_test_hook $hook) { + $hook->info[] = 2; + } + + public static function listen3(totara_core_test_hook $hook) { + $hook->info[] = 3; + throw new \Exception('some problem'); + } +} diff --git a/totara/core/tests/hook_test.php b/totara/core/tests/hook_test.php new file mode 100644 index 0000000..0ff18ef --- /dev/null +++ b/totara/core/tests/hook_test.php @@ -0,0 +1,157 @@ +. + * + * @author Petr Skoda + * @package totara_core + */ + +/** + * Tests for hook manager, base class and callbacks. + * + * @author Petr Skoda + * @package totara_core + */ + +defined('MOODLE_INTERNAL') || die(); + +class totara_core_hook_testcase extends advanced_testcase { + public function test_callback_parsing() { + require_once(__DIR__ . '/fixtures/test_hook.php'); + + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + ); + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + $this->assertDebuggingNotCalled(); + + $callbacks = array( + array( + 'xhookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + ); + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + $this->assertDebuggingCalled('Invalid \'hookname\' detected in phpunit callback definition'); + + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'xcallback' => array('totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + ); + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + $this->assertDebuggingCalled('Invalid \'callback\' detected in phpunit callback definition'); + + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'xxxtotara/core/tests/fixtures/test_hook_listener.php', + ), + ); + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + $this->assertDebuggingCalled('Invalid \'includefile\' detected in phpunit callback definition'); + } + + public function test_execute() { + require_once(__DIR__ . '/fixtures/test_hook.php'); + + // This is the format used in db/hooks.php files. + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen2'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('\\totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + 'priority' => 100, + ), + ); + + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + + $hook = new totara_core_test_hook(); + $this->assertSame(array(), $hook->info); + $result = $hook->execute(); + $this->assertSame($result, $hook); + $this->assertSame(array(1, 2), $hook->info); + } + + public function test_callback_definition_problems() { + require_once(__DIR__ . '/fixtures/test_hook.php'); + + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listenxxx'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + ); + + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + + $hook = new totara_core_test_hook(); + $result = $hook->execute(); + $this->assertSame($hook, $result); + $this->assertDebuggingCalled("Cannot execute hook callback 'array (\n 0 => 'totara_core_test_hook_listener',\n 1 => 'listenxxx',\n)'"); + } + + public function test_callback_exception_problems() { + require_once(__DIR__ . '/fixtures/test_hook.php'); + + // This is the format used in db/hooks.php files. + $callbacks = array( + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen2'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + ), + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('totara_core_test_hook_listener', 'listen3'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + 'priority' => 50, + ), + array( + 'hookname' => 'totara_core_test_hook', + 'callback' => array('\\totara_core_test_hook_listener', 'listen1'), + 'includefile' => 'totara/core/tests/fixtures/test_hook_listener.php', + 'priority' => 100, + ), + ); + + \totara_core\hook\manager::phpunit_replace_callbacks($callbacks); + + $hook = new totara_core_test_hook(); + $this->assertSame(array(), $hook->info); + $result = $hook->execute(); + $this->assertSame($result, $hook); + $this->assertSame(array(1, 3, 2), $hook->info); + $this->assertDebuggingCalled("Exception encountered in hook callback 'array (\n 0 => 'totara_core_test_hook_listener',\n 1 => 'listen3',\n)': some problem"); + } +} -- 2.7.2