ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç classes/document_icon.php000064400000003614152265255700011555 0ustar00. /** * Document icon class. * * @package core_search * @copyright 2018 Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Represents a document icon. * * @package core_search * @copyright 2018 Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_icon { /** * Icon file name. * @var string */ protected $name; /** Icon file component. * @var string */ protected $component; /** * Constructor. * * @param string $name Icon name. * @param string $component Icon component. */ public function __construct($name, $component = 'moodle') { $this->name = $name; $this->component = $component; } /** * Returns name of the icon file. * * @return string */ public function get_name() { return $this->name; } /** * Returns the component of the icon file. * * @return string */ public function get_component() { return $this->component; } } classes/external.php000064400000010516152265255700010550 0ustar00. /** * Handles external (web service) function calls related to search. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use core_user\external\user_summary_exporter; /** * Handles external (web service) function calls related to search. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class external extends \core_external\external_api { /** * Returns parameter types for get_relevant_users function. * * @return external_function_parameters Parameters */ public static function get_relevant_users_parameters() { return new external_function_parameters([ 'query' => new external_value( PARAM_RAW, 'Query string (full or partial user full name or other details)' ), 'courseid' => new external_value(PARAM_INT, 'Course id (0 if none)'), ]); } /** * Returns result type for get_relevant_users function. * * @return external_description Result type */ public static function get_relevant_users_returns() { return new external_multiple_structure( new external_single_structure([ 'id' => new external_value(PARAM_INT, 'User id'), 'fullname' => new external_value(PARAM_RAW, 'Full name as text'), 'profileimageurlsmall' => new external_value(PARAM_URL, 'URL to small profile image') ]) ); } /** * Searches for users given a query, taking into account the current user's permissions and * possibly a course to check within. * * @param string $query Query text * @param int $courseid Course id or 0 if no restriction * @return array Defined return structure */ public static function get_relevant_users($query, $courseid) { global $CFG, $PAGE; // Validate parameter. [ 'query' => $query, 'courseid' => $courseid, ] = self::validate_parameters(self::get_relevant_users_parameters(), [ 'query' => $query, 'courseid' => $courseid, ]); // Validate the context (search page is always system context). $systemcontext = \context_system::instance(); self::validate_context($systemcontext); // Get course object too. if ($courseid) { $coursecontext = \context_course::instance($courseid); } else { $coursecontext = null; } // If not logged in, can't see anyone when forceloginforprofiles is on. if (!empty($CFG->forceloginforprofiles)) { if (!isloggedin() || isguestuser()) { return []; } } $users = \core_user::search($query, $coursecontext); $result = []; foreach ($users as $user) { // Get a standard exported user object. $fulldetails = (new user_summary_exporter($user))->export($PAGE->get_renderer('core')); // To avoid leaking private data to students, only include the specific information we // are going to display (and not the email, idnumber, etc). $result[] = (object)['id' => $fulldetails->id, 'fullname' => $fulldetails->fullname, 'profileimageurlsmall' => $fulldetails->profileimageurlsmall]; } return $result; } } classes/external/document_exporter.php000064400000011235152265255700014315 0ustar00. namespace core_search\external; use core\external\exporter; /** * Contains related class for displaying information of a search result. * * @package core_search * @since Moodle 4.3 */ class document_exporter extends exporter { /** * Return the list of properties. * * @return array */ protected static function define_properties() { return [ 'itemid' => [ 'type' => PARAM_INT, 'description' => 'unique id in the search area scope', ], 'componentname' => [ 'type' => PARAM_ALPHANUMEXT, 'description' => 'component name', ], 'areaname' => [ 'type' => PARAM_ALPHANUMEXT, 'description' => 'search area name', ], 'courseurl' => [ 'type' => PARAM_URL, 'description' => 'result course url', ], 'coursefullname' => [ 'type' => PARAM_RAW, 'description' => 'result course fullname', ], 'timemodified' => [ 'type' => PARAM_INT, 'description' => 'result modified time', ], 'title' => [ 'type' => PARAM_RAW, 'description' => 'result title', ], 'docurl' => [ 'type' => PARAM_URL, 'description' => 'result url', ], 'iconurl' => [ 'type' => PARAM_URL, 'description' => 'icon url', 'optional' => true, 'default' => '', 'null' => NULL_ALLOWED, ], 'content' => [ 'type' => PARAM_RAW, 'description' => 'result contents', 'optional' => true, 'default' => '', 'null' => NULL_ALLOWED, ], 'contextid' => [ 'type' => PARAM_INT, 'description' => 'result context id', ], 'contexturl' => [ 'type' => PARAM_URL, 'description' => 'result context url', ], 'description1' => [ 'type' => PARAM_RAW, 'description' => 'extra result contents, depends on the search area', 'optional' => true, 'default' => '', 'null' => NULL_ALLOWED, ], 'description2' => [ 'type' => PARAM_RAW, 'description' => 'extra result contents, depends on the search area', 'optional' => true, 'default' => '', 'null' => NULL_ALLOWED, ], 'multiplefiles' => [ 'type' => PARAM_INT, 'description' => 'whether multiple files are returned or not', 'optional' => true, ], 'filenames' => [ 'type' => PARAM_RAW, 'description' => 'result file names if present', 'muultiple' => true, 'optional' => true, ], 'filename' => [ 'type' => PARAM_RAW, 'description' => 'result file name if present', 'optional' => true, ], 'userid' => [ 'type' => PARAM_INT, 'description' => 'user id', 'optional' => true, ], 'userurl' => [ 'type' => PARAM_URL, 'description' => 'user url', 'optional' => true, ], 'userfullname' => [ 'type' => PARAM_RAW, 'description' => 'user fullname', 'optional' => true, ], 'textformat' => [ 'type' => PARAM_INT, 'description' => 'text fields format, it is the same for all of them', ] ]; } } classes/external/view_results.php000064400000011535152265255700013305 0ustar00. namespace core_search\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_external\external_value; use core_external\external_warnings; use moodle_exception; /** * External function for trigger view search results event. * * @package core_search * @copyright 2023 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 4.3 */ class view_results extends external_api { /** * Webservice parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters( [ 'query' => new external_value(PARAM_NOTAGS, 'the search query'), 'filters' => new external_single_structure( [ 'title' => new external_value(PARAM_NOTAGS, 'result title', VALUE_OPTIONAL), 'areaids' => new external_multiple_structure( new external_value(PARAM_RAW, 'areaid'), 'restrict results to these areas', VALUE_DEFAULT, [] ), 'courseids' => new external_multiple_structure( new external_value(PARAM_INT, 'courseid'), 'restrict results to these courses', VALUE_DEFAULT, [] ), 'timestart' => new external_value(PARAM_INT, 'docs modified after this date', VALUE_DEFAULT, 0), 'timeend' => new external_value(PARAM_INT, 'docs modified before this date', VALUE_DEFAULT, 0) ], 'filters to apply', VALUE_DEFAULT, [] ), 'page' => new external_value(PARAM_INT, 'results page number starting from 0, defaults to the first page', VALUE_DEFAULT, 0) ] ); } /** * Trigger view results event. * * @param string $query the search query * @param array $filters filters to apply * @param int $page results page * @return array status and warnings */ public static function execute(string $query, array $filters = [], int $page = 0): array { $params = self::validate_parameters(self::execute_parameters(), [ 'query' => $query, 'filters' => $filters, 'page' => $page, ] ); $system = \context_system::instance(); external_api::validate_context($system); require_capability('moodle/search:query', $system); if (\core_search\manager::is_global_search_enabled() === false) { throw new moodle_exception('globalsearchdisabled', 'search'); } $filters = new \stdClass(); $filters->title = $params['filters']['title'] ?? ''; $filters->timestart = $params['filters']['timestart'] ?? 0; $filters->timeend = $params['filters']['timeend'] ?? 0; $filters->areaids = $params['filters']['areaids'] ?? []; $filters->courseids = $params['filters']['courseids'] ?? []; \core_search\manager::trigger_search_results_viewed([ 'q' => $params['query'], 'page' => $params['page'], 'title' => !empty($filters->title) ? $filters->title : '', 'areaids' => !empty($filters->areaids) ? $filters->areaids : [], 'courseids' => !empty($filters->courseids) ? $filters->courseids : [], 'timestart' => isset($filters->timestart) ? $filters->timestart : 0, 'timeend' => isset($filters->timeend) ? $filters->timeend : 0 ]); return ['status' => true, 'warnings' => []]; } /** * Webservice returns. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings() ] ); } } classes/external/get_search_areas_list.php000064400000010052152265255700015055 0ustar00. namespace core_search\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_external\external_value; use core_external\external_warnings; use \core_search\manager; use moodle_exception; /** * External function for return the list of search areas. * * @package core_search * @copyright 2023 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 4.3 */ class get_search_areas_list extends external_api { /** * Webservice parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters( [ 'cat' => new external_value(PARAM_NOTAGS, 'category to filter areas', VALUE_DEFAULT, ''), ] ); } /** * Return list of search areas. * * @param string $cat category to filter areas * @return array search areas and warnings */ public static function execute(string $cat = ''): array { $params = self::validate_parameters(self::execute_parameters(), ['cat' => $cat]); $system = \context_system::instance(); external_api::validate_context($system); require_capability('moodle/search:query', $system); if (manager::is_global_search_enabled() === false) { throw new moodle_exception('globalsearchdisabled', 'search'); } $areas = []; $allsearchareas = manager::get_search_area_categories(); $enabledsearchareas = manager::get_search_areas_list(true); foreach ($allsearchareas as $categoryid => $searchareacategory) { if (!empty($params['cat']) && $params['cat'] != $categoryid) { continue; } $searchareas = $searchareacategory->get_areas(); $catname = $searchareacategory->get_visiblename(); foreach ($searchareas as $areaid => $searcharea) { if (key_exists($areaid, $enabledsearchareas)) { $name = $searcharea->get_visible_name(); $areas[$name] = ['id' => $areaid, 'name' => $name, 'categoryid' => $categoryid, 'categoryname' => $catname]; } } } ksort($areas); return ['areas' => $areas, 'warnings' => []]; } /** * Webservice returns. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'areas' => new external_multiple_structure( new external_single_structure( [ 'id' => new external_value(PARAM_ALPHANUMEXT, 'search area id'), 'categoryid' => new external_value(PARAM_NOTAGS, 'category id'), 'categoryname' => new external_value(PARAM_NOTAGS, 'category name'), 'name' => new external_value(PARAM_TEXT, 'search area name'), ], 'Search area' ), 'Search areas' ), 'warnings' => new external_warnings() ] ); } } classes/external/get_top_results.php000064400000010340152265255700013765 0ustar00. namespace core_search\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_multiple_structure; use moodle_exception; /** * External function for retrieving top search results. * * @package core_search * @copyright 2023 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 4.3 */ class get_top_results extends external_api { /** * Webservice parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { $baseparameters = get_results::execute_parameters(); return new external_function_parameters( [ 'query' => $baseparameters->keys['query'], 'filters' => $baseparameters->keys['filters'], ] ); } /** * Gets top search results based on the provided query and filters. * * @param string $query the search query * @param array $filters filters to apply * @return array search results */ public static function execute(string $query, array $filters = []): array { global $PAGE; $params = self::validate_parameters(self::execute_parameters(), [ 'query' => $query, 'filters' => $filters, ] ); $system = \context_system::instance(); external_api::validate_context($system); require_capability('moodle/search:query', $system); if (\core_search\manager::is_global_search_enabled() === false) { throw new moodle_exception('globalsearchdisabled', 'search'); } $search = \core_search\manager::instance(); $data = new \stdClass(); // First, mandatory parameters for consistency with web. $data->q = $params['query']; $data->title = $params['filters']['title'] ?? ''; $data->timestart = $params['filters']['timestart'] ?? 0; $data->timeend = $params['filters']['timeend'] ?? 0; $data->areaids = $params['filters']['areaids'] ?? []; $data->courseids = $params['filters']['courseids'] ?? []; $data->contextids = $params['filters']['contextids'] ?? []; $data->userids = $params['filters']['userids'] ?? []; $data->groupids = $params['filters']['groupids'] ?? []; $cat = $params['filters']['cat'] ?? ''; if (\core_search\manager::is_search_area_categories_enabled()) { $cat = \core_search\manager::get_search_area_category_by_name($cat); } if ($cat instanceof \core_search\area_category) { $data->cat = $cat->get_name(); } $docs = $search->search_top($data); $return = [ 'warnings' => [], 'results' => [] ]; // Convert results to simple data structures. if ($docs) { foreach ($docs as $doc) { $return['results'][] = $doc->export_doc($PAGE->get_renderer('core')); } } return $return; } /** * Webservice returns. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'results' => new external_multiple_structure( \core_search\external\document_exporter::get_read_structure() ), ] ); } } classes/external/get_results.php000064400000014747152265255700013122 0ustar00. namespace core_search\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_multiple_structure; use core_external\external_value; use moodle_exception; /** * External function for retrieving search results. * * @package core_search * @copyright 2023 David Monllao & Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 4.3 */ class get_results extends external_api { /** * Webservice parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters( [ 'query' => new external_value(PARAM_NOTAGS, 'the search query'), 'filters' => new external_single_structure( [ 'title' => new external_value(PARAM_NOTAGS, 'result title', VALUE_OPTIONAL), 'areaids' => new external_multiple_structure( new external_value(PARAM_ALPHANUMEXT, 'areaid'), 'restrict results to these areas', VALUE_DEFAULT, [] ), 'courseids' => new external_multiple_structure( new external_value(PARAM_INT, 'courseid'), 'restrict results to these courses', VALUE_DEFAULT, [] ), 'contextids' => new external_multiple_structure( new external_value(PARAM_INT, 'contextid'), 'restrict results to these contexts', VALUE_DEFAULT, [] ), 'cat' => new external_value(PARAM_NOTAGS, 'category to filter areas', VALUE_DEFAULT, ''), 'userids' => new external_multiple_structure( new external_value(PARAM_INT, 'userid'), 'restrict results to these users', VALUE_DEFAULT, [] ), 'groupids' => new external_multiple_structure( new external_value(PARAM_INT, 'groupid'), 'restrict results to these groups', VALUE_DEFAULT, [] ), 'mycoursesonly' => new external_value(PARAM_BOOL, 'only results from my courses', VALUE_DEFAULT, false), 'order' => new external_value(PARAM_ALPHA, 'how to order', VALUE_DEFAULT, ''), 'timestart' => new external_value(PARAM_INT, 'docs modified after this date', VALUE_DEFAULT, 0), 'timeend' => new external_value(PARAM_INT, 'docs modified before this date', VALUE_DEFAULT, 0) ], 'filters to apply', VALUE_DEFAULT, [] ), 'page' => new external_value(PARAM_INT, 'results page number starting from 0, defaults to the first page', VALUE_DEFAULT, 0) ] ); } /** * Gets global search results based on the provided query and filters. * * @param string $query the search query * @param array $filters filters to apply * @param int $page results page * @return array search results */ public static function execute(string $query, array $filters = [], int $page = 0): array { global $PAGE; $params = self::validate_parameters(self::execute_parameters(), [ 'query' => $query, 'filters' => $filters, 'page' => $page, ] ); $system = \context_system::instance(); external_api::validate_context($system); require_capability('moodle/search:query', $system); if (\core_search\manager::is_global_search_enabled() === false) { throw new moodle_exception('globalsearchdisabled', 'search'); } $search = \core_search\manager::instance(); $data = new \stdClass(); // First, mandatory parameters for consistency with web. $data->q = $params['query']; $data->title = $params['filters']['title'] ?? ''; $data->timestart = $params['filters']['timestart'] ?? 0; $data->timeend = $params['filters']['timeend'] ?? 0; $data->areaids = $params['filters']['areaids'] ?? []; $data->courseids = $params['filters']['courseids'] ?? []; $data->contextids = $params['filters']['contextids'] ?? []; $data->userids = $params['filters']['userids'] ?? []; $data->groupids = $params['filters']['groupids'] ?? []; $cat = $params['filters']['cat'] ?? ''; if (\core_search\manager::is_search_area_categories_enabled()) { $cat = \core_search\manager::get_search_area_category_by_name($cat); } if ($cat instanceof \core_search\area_category) { $data->cat = $cat->get_name(); } $docs = $search->paged_search($data, $page); $return = [ 'totalcount' => $docs->totalcount, 'warnings' => [], 'results' => [] ]; // Convert results to simple data structures. if ($docs) { foreach ($docs->results as $doc) { $return['results'][] = $doc->export_doc($PAGE->get_renderer('core')); } } return $return; } /** * Webservice returns. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'totalcount' => new external_value(PARAM_INT, 'Total number of results'), 'results' => new external_multiple_structure( \core_search\external\document_exporter::get_read_structure() ), ] ); } } classes/privacy/provider.php000064400000002706152265255700012237 0ustar00. /** * Privacy Subsystem implementation for core_search. * * @package core_search * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for core_search implementing null_provider. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } classes/engine.php000064400000071221152265255700010173 0ustar00. /** * Base class for search engines. * * All search engines must extend this class. * * @package core_search * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base class for search engines. * * All search engines must extend this class. * * @package core_search * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class engine { /** * The search engine configuration. * * @var \stdClass */ protected $config = null; /** * Last executed query error, if there was any. * @var string */ protected $queryerror = null; /** * @var array Internal cache. */ protected $cachedareas = array(); /** * @var array Internal cache. */ protected $cachedcourses = array(); /** * User data required to show their fullnames. Indexed by userid. * * @var \stdClass[] */ protected static $cachedusers = array(); /** * @var string Frankenstyle plugin name. */ protected $pluginname = null; /** * @var bool If true, should skip schema validity check when checking the search engine is ready */ protected $skipschemacheck = false; /** * Initialises the search engine configuration. * * Search engine availability should be checked separately. * * The alternate configuration option is only used to construct a special second copy of the * search engine object, as described in {@see has_alternate_configuration}. * * @param bool $alternateconfiguration If true, use alternate configuration settings * @return void */ public function __construct(bool $alternateconfiguration = false) { $classname = get_class($this); if (strpos($classname, '\\') === false) { throw new \coding_exception('"' . $classname . '" class should specify its component namespace and it should be named engine.'); } else if (strpos($classname, '_') === false) { throw new \coding_exception('"' . $classname . '" class namespace should be its frankenstyle name'); } // This is search_xxxx config. $this->pluginname = substr($classname, 0, strpos($classname, '\\')); if ($config = get_config($this->pluginname)) { $this->config = $config; } else { $this->config = new stdClass(); } // For alternate configuration, automatically replace normal configuration values with // those beginning with 'alternate'. if ($alternateconfiguration) { foreach ((array)$this->config as $key => $value) { if (preg_match('~^alternate(.*)$~', $key, $matches)) { $this->config->{$matches[1]} = $value; } } } // Flag just in case engine needs to know it is using the alternate configuration. $this->config->alternateconfiguration = $alternateconfiguration; } /** * Returns a course instance checking internal caching. * * @param int $courseid * @return stdClass */ protected function get_course($courseid) { if (!empty($this->cachedcourses[$courseid])) { return $this->cachedcourses[$courseid]; } // No need to clone, only read. $this->cachedcourses[$courseid] = get_course($courseid, false); return $this->cachedcourses[$courseid]; } /** * Returns user data checking the internal static cache. * * Including here the minimum required user information as this may grow big. * * @param int $userid * @return stdClass */ public function get_user($userid) { global $DB; if (empty(self::$cachedusers[$userid])) { $userfieldsapi = \core_user\fields::for_name(); $fields = $userfieldsapi->get_sql('', false, '', '', false)->selects; self::$cachedusers[$userid] = $DB->get_record('user', array('id' => $userid), 'id, ' . $fields); } return self::$cachedusers[$userid]; } /** * Clears the users cache. * * @return null */ public static function clear_users_cache() { self::$cachedusers = []; } /** * Returns a search instance of the specified area checking internal caching. * * @param string $areaid Area id * @return \core_search\base */ protected function get_search_area($areaid) { if (isset($this->cachedareas[$areaid]) && $this->cachedareas[$areaid] === false) { // We already checked that area and it is not available. return false; } if (!isset($this->cachedareas[$areaid])) { // First result that matches this area. $this->cachedareas[$areaid] = \core_search\manager::get_search_area($areaid); if ($this->cachedareas[$areaid] === false) { // The area does not exist or it is not available any more. $this->cachedareas[$areaid] = false; return false; } if (!$this->cachedareas[$areaid]->is_enabled()) { // We skip the area if it is not enabled. // Marking it as false so next time we don' need to check it again. $this->cachedareas[$areaid] = false; return false; } } return $this->cachedareas[$areaid]; } /** * Returns a document instance prepared to be rendered. * * @param \core_search\base $searcharea * @param array $docdata * @return \core_search\document */ protected function to_document(\core_search\base $searcharea, $docdata) { list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($docdata['areaid']); $doc = \core_search\document_factory::instance($docdata['itemid'], $componentname, $areaname, $this); $doc->set_data_from_engine($docdata); $doc->set_doc_url($searcharea->get_doc_url($doc)); $doc->set_context_url($searcharea->get_context_url($doc)); $doc->set_doc_icon($searcharea->get_doc_icon($doc)); // Uses the internal caches to get required data needed to render the document later. $course = $this->get_course($doc->get('courseid')); $doc->set_extra('coursefullname', $course->fullname); if ($doc->is_set('userid')) { $user = $this->get_user($doc->get('userid')); $doc->set_extra('userfullname', fullname($user)); } return $doc; } /** * Loop through given iterator of search documents * and and have the search engine back end add them * to the index. * * @param \iterator $iterator the iterator of documents to index * @param base $searcharea the area for the documents to index * @param array $options document indexing options * @return array Processed document counts */ public function add_documents($iterator, $searcharea, $options) { $numrecords = 0; $numdocs = 0; $numdocsignored = 0; $numbatches = 0; $lastindexeddoc = 0; $firstindexeddoc = 0; $partial = false; $lastprogress = manager::get_current_time(); $batchmode = $this->supports_add_document_batch(); $currentbatch = []; foreach ($iterator as $document) { // Stop if we have exceeded the time limit (and there are still more items). Always // do at least one second's worth of documents otherwise it will never make progress. if ($lastindexeddoc !== $firstindexeddoc && !empty($options['stopat']) && manager::get_current_time() >= $options['stopat']) { $partial = true; break; } if (!$document instanceof \core_search\document) { continue; } if (isset($options['lastindexedtime']) && $options['lastindexedtime'] == 0) { // If we have never indexed this area before, it must be new. $document->set_is_new(true); } if ($options['indexfiles']) { // Attach files if we are indexing. $searcharea->attach_files($document); } if ($batchmode && strlen($document->get('content')) <= $this->get_batch_max_content()) { $currentbatch[] = $document; if (count($currentbatch) >= $this->get_batch_max_documents()) { [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']); $numdocs += $processed; $numdocsignored += $failed; $numbatches += $batches; $currentbatch = []; } } else { if ($this->add_document($document, $options['indexfiles'])) { $numdocs++; } else { $numdocsignored++; } $numbatches++; } $lastindexeddoc = $document->get('modified'); if (!$firstindexeddoc) { $firstindexeddoc = $lastindexeddoc; } $numrecords++; // If indexing the area takes a long time, periodically output progress information. if (isset($options['progress'])) { $now = manager::get_current_time(); if ($now - $lastprogress >= manager::DISPLAY_INDEXING_PROGRESS_EVERY) { $lastprogress = $now; // The first date format is the same used in \core\cron::trace_time_and_memory(). $options['progress']->output(date('H:i:s', (int)$now) . ': Done to ' . userdate( $lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')), 1); } } } // Add remaining documents from batch. if ($batchmode && $currentbatch) { [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']); $numdocs += $processed; $numdocsignored += $failed; $numbatches += $batches; } return [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $numbatches]; } /** * Returns the plugin name. * * @return string Frankenstyle plugin name. */ public function get_plugin_name() { return $this->pluginname; } /** * Gets the document class used by this search engine. * * Search engines can overwrite \core_search\document with \search_ENGINENAME\document class. * * Looks for a document class in the current search engine namespace, falling back to \core_search\document. * Publicly available because search areas do not have access to the engine details, * \core_search\document_factory accesses this function. * * @return string */ public function get_document_classname() { $classname = $this->pluginname . '\\document'; if (!class_exists($classname)) { $classname = '\\core_search\\document'; } return $classname; } /** * Run any pre-indexing operations. * * Should be overwritten if the search engine needs to do any pre index preparation. * * @param bool $fullindex True if a full index will be performed * @return void */ public function index_starting($fullindex = false) { // Nothing by default. } /** * Run any post indexing operations. * * Should be overwritten if the search engine needs to do any post index cleanup. * * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index was performed * @return void */ public function index_complete($numdocs = 0, $fullindex = false) { // Nothing by default. } /** * Do anything that may need to be done before an area is indexed. * * @param \core_search\base $searcharea The search area that was complete * @param bool $fullindex True if a full index is being performed * @return void */ public function area_index_starting($searcharea, $fullindex = false) { // Nothing by default. } /** * Do any area cleanup needed, and do anything to confirm contents. * * Return false to prevent the search area completed time and stats from being updated. * * @param \core_search\base $searcharea The search area that was complete * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index is being performed * @return bool True means that data is considered indexed */ public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) { return true; } /** * Optimizes the search engine. * * Should be overwritten if the search engine can optimize its contents. * * @return void */ public function optimize() { // Nothing by default. mtrace('The ' . get_string('pluginname', $this->get_plugin_name()) . ' search engine does not require automatic optimization.'); } /** * Does the system satisfy all the requirements. * * Should be overwritten if the search engine has any system dependencies * that needs to be checked. * * @return bool */ public function is_installed() { return true; } /** * Returns any error reported by the search engine when executing the provided query. * * It should be called from static::execute_query when an exception is triggered. * * @return string */ public function get_query_error() { return $this->queryerror; } /** * Returns the total number of documents available for the most recent call to execute_query. * * This can be an estimate, but should get more accurate the higher the limited passed to execute_query is. * To do that, the engine can use (actual result returned count + count of unchecked documents), or * (total possible docs - docs that have been checked and rejected). * * Engine can limit to manager::MAX_RESULTS if there is cost to determining more. * If this cannot be computed in a reasonable way, manager::MAX_RESULTS may be returned. * * @return int */ abstract public function get_query_total_count(); /** * Return true if file indexing is supported and enabled. False otherwise. * * @return bool */ public function file_indexing_enabled() { return false; } /** * Clears the current query error value. * * @return void */ public function clear_query_error() { $this->queryerror = null; } /** * Is the server ready to use? * * This should also check that the search engine configuration is ok. * * If the function $this->should_skip_schema_check() returns true, then this function may leave * out time-consuming checks that the schema is valid. (This allows for improved performance on * critical pages such as the main search form.) * * @return true|string Returns true if all good or an error string. */ abstract function is_server_ready(); /** * Tells the search engine to skip any time-consuming checks that it might do as part of the * is_server_ready function, and only carry out a basic check that it can contact the server. * * This setting is not remembered and applies only to the current request. * * @since Moodle 3.5 * @param bool $skip True to skip the checks, false to start checking again */ public function skip_schema_check($skip = true) { $this->skipschemacheck = $skip; } /** * For use by subclasses. The engine can call this inside is_server_ready to check whether it * should skip time-consuming schema checks. * * @since Moodle 3.5 * @return bool True if schema checks should be skipped */ protected function should_skip_schema_check() { return $this->skipschemacheck; } /** * Adds a document to the search engine. * * @param document $document * @param bool $fileindexing True if file indexing is to be used * @return bool False if the file was skipped or failed, true on success */ abstract function add_document($document, $fileindexing = false); /** * Adds multiple documents to the search engine. * * It should return the number successfully processed, and the number of batches they were * processed in (for example if you add 100 documents and there is an error processing one of * those documents, and it took 4 batches, it would return [99, 1, 4]). * * If the engine implements this, it should return true to {@see supports_add_document_batch}. * * The system will only call this function with up to {@see get_batch_max_documents} documents, * and each document in the batch will have content no larger than specified by * {@see get_batch_max_content}. * * @param document[] $documents Documents to add * @param bool $fileindexing True if file indexing is to be used * @return int[] Array of three elements, successfully processed, failed processed, batch count */ public function add_document_batch(array $documents, bool $fileindexing = false): array { throw new \coding_exception('add_document_batch not supported by this engine'); } /** * Executes the query on the engine. * * Implementations of this function should check user context array to limit the results to contexts where the * user has access. They should also limit the owneruserid field to manger::NO_OWNER_ID or the current user's id. * Engines must use area->check_access() to confirm user access. * * Engines should reasonably attempt to fill up to limit with valid results if they are available. * * The $filters object may include the following fields (optional except q): * - q: value of main search field; results should include this text * - title: if included, title must match this search * - areaids: array of search area id strings (only these areas will be searched) * - courseids: array of course ids (only these courses will be searched) * - groupids: array of group ids (only results specifically from these groupids will be * searched) - this option will be ignored if the search engine doesn't support groups * * The $accessinfo parameter has two different values (for historical compatibility). If the * engine returns false to supports_group_filtering then it is an array of user contexts, or * true if the user can access all contexts. (This parameter used to be called $usercontexts.) * If the engine returns true to supports_group_filtering then it will be an object containing * these fields: * - everything (true if admin is searching with no restrictions) * - usercontexts (same as above) * - separategroupscontexts (array of context ids where separate groups are used) * - visiblegroupscontextsareas (array of subset of those where some areas use visible groups) * - usergroups (array of relevant group ids that user belongs to) * * The engine should apply group restrictions to those contexts listed in the * 'separategroupscontexts' array. In these contexts, it shouled only include results if the * groupid is not set, or if the groupid matches one of the values in USER_GROUPS array, or * if the search area is one of those listed in 'visiblegroupscontextsareas' for that context. * * @param \stdClass $filters Query and filters to apply. * @param \stdClass $accessinfo Information about the contexts the user can access * @param int $limit The maximum number of results to return. If empty, limit to manager::MAX_RESULTS. * @return \core_search\document[] Results or false if no results */ abstract public function execute_query($filters, $accessinfo, $limit = 0); /** * Delete all documents. * * @param string $areaid To filter by area * @return void */ abstract function delete($areaid = null); /** * Deletes information related to a specific context id. This should be used when the context * itself is deleted from Moodle. * * This only deletes information for the specified context - not for any child contexts. * * This function is optional; if not supported it will return false and the information will * not be deleted from the search index. * * If an engine implements this function it should also implement delete_index_for_course; * otherwise, nothing will be deleted when users delete an entire course at once. * * @param int $oldcontextid ID of context that has been deleted * @return bool True if implemented * @throws \core_search\engine_exception Engines may throw this exception for any problem */ public function delete_index_for_context(int $oldcontextid) { return false; } /** * Deletes information related to a specific course id. This should be used when the course * itself is deleted from Moodle. * * This deletes all information relating to that course from the index, including all child * contexts. * * This function is optional; if not supported it will return false and the information will * not be deleted from the search index. * * If an engine implements this function then, ideally, it should also implement * delete_index_for_context so that deletion of single activities/blocks also works. * * @param int $oldcourseid ID of course that has been deleted * @return bool True if implemented * @throws \core_search\engine_exception Engines may throw this exception for any problem */ public function delete_index_for_course(int $oldcourseid) { return false; } /** * Checks that the schema is the latest version. If the version stored in config does not match * the current, this function will attempt to upgrade the schema. * * @return bool|string True if schema is OK, a string if user needs to take action */ public function check_latest_schema() { if (empty($this->config->schemaversion)) { $currentversion = 0; } else { $currentversion = $this->config->schemaversion; } if ($currentversion < document::SCHEMA_VERSION) { return $this->update_schema((int)$currentversion, (int)document::SCHEMA_VERSION); } else { return true; } } /** * Usually called by the engine; marks that the schema has been updated. * * @param int $version Records the schema version now applied */ public function record_applied_schema_version($version) { set_config('schemaversion', $version, $this->pluginname); } /** * Requests the search engine to upgrade the schema. The engine should update the schema if * possible/necessary, and should ensure that record_applied_schema_version is called as a * result. * * If it is not possible to upgrade the schema at the moment, it can do nothing and return; the * function will be called again next time search is initialised. * * The default implementation just returns, with a DEBUG_DEVELOPER warning. * * @param int $oldversion Old schema version * @param int $newversion New schema version * @return bool|string True if schema is updated successfully, a string if it needs updating manually */ protected function update_schema($oldversion, $newversion) { debugging('Unable to update search engine schema: ' . $this->pluginname, DEBUG_DEVELOPER); return get_string('schemanotupdated', 'search'); } /** * Checks if this search engine supports groups. * * Note that returning true to this function causes the parameters to execute_query to be * passed differently! * * In order to implement groups and return true to this function, the search engine should: * * 1. Handle the fields ->separategroupscontexts and ->usergroups in the $accessinfo parameter * to execute_query (ideally, using these to automatically restrict search results). * 2. Support the optional groupids parameter in the $filter parameter for execute_query to * restrict results to only those where the stored groupid matches the given value. * * @return bool True if this engine supports searching by group id field */ public function supports_group_filtering() { return false; } /** * Obtain a list of results orders (and names for them) that are supported by this * search engine in the given context. * * By default, engines sort by relevance only. * * @param \context $context Context that the user requested search from * @return array Array from order name => display text */ public function get_supported_orders(\context $context) { return ['relevance' => get_string('order_relevance', 'search')]; } /** * Checks if the search engine supports searching by user. * * If it returns true to this function, the search engine should support the 'userids' option * in the $filters value passed to execute_query(), returning only items where the userid in * the search document matches one of those user ids. * * @return bool True if the search engine supports searching by user */ public function supports_users() { return false; } /** * Checks if the search engine supports adding documents in a batch. * * If it returns true to this function, the search engine must implement the add_document_batch * function. * * @return bool True if the search engine supports adding documents in a batch */ public function supports_add_document_batch(): bool { return false; } /** * Gets the maximum number of documents to send together in batch mode. * * Only relevant if the engine returns true to {@see supports_add_document_batch}. * * Can be overridden by search engine if required. * * @var int Number of documents to send together in batch mode, default 100. */ public function get_batch_max_documents(): int { return 100; } /** * Gets the maximum size of document content to be included in a shared batch (if the * document is bigger then it will be sent on its own; batching does not provide a performance * improvement for big documents anyway). * * Only relevant if the engine returns true to {@see supports_add_document_batch}. * * Can be overridden by search engine if required. * * @return int Max size in bytes, default 1MB */ public function get_batch_max_content(): int { return 1024 * 1024; } /** * Checks if the search engine has an alternate configuration. * * This is used where the same search engine class supports two different configurations, * which are both shown on the settings screen. The alternate configuration is selected by * passing 'true' parameter to the constructor. * * The feature is used when a different connection is in use for indexing vs. querying * the search engine. * * This function should only return true if the engine supports an alternate configuration * and the user has filled in the settings. (We do not need to test they are valid, that will * happen as normal.) * * @return bool True if an alternate configuration is defined */ public function has_alternate_configuration(): bool { return false; } } classes/area_category.php000064400000006070152265255700011533 0ustar00. /** * Search area category. * * @package core_search * @copyright Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search area category. * * @package core_search * @copyright Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class area_category { /** * Category name. * @var string */ protected $name; /** * Category visible name. * @var string */ protected $visiblename; /** * Category order. * @var int */ protected $order = 0; /** * Category areas. * @var \core_search\base[] */ protected $areas = []; /** * Constructor. * * @param string $name Unique name of the category. * @param string $visiblename Visible name of the category. * @param int $order Category position in the list (smaller numbers will be displayed first). * @param \core_search\base[] $areas A list of search areas associated with this category. */ public function __construct(string $name, string $visiblename, int $order = 0, array $areas = []) { $this->name = $name; $this->visiblename = $visiblename; $this->order = $order; $this->set_areas($areas); } /** * Get name. * * @return string */ public function get_name() { return $this->name; } /** * Get visible name. * * @return string */ public function get_visiblename() { return $this->visiblename; } /** * Get order to display. * * @return int */ public function get_order() { return $this->order; } /** * Return a keyed by area id list of areas for this category. * * @return \core_search\base[] */ public function get_areas() { return $this->areas; } /** * Set list of search areas for this category, * * @param \core_search\base[] $areas */ public function set_areas(array $areas) { foreach ($areas as $area) { if ($area instanceof base && !key_exists($area->get_area_id(), $this->areas)) { $this->areas[$area->get_area_id()] = $area; } } } } classes/document_factory.php000064400000005401152265255700012270 0ustar00. /** * Search documents factory. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search document factory. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_factory { /** * The document class used by search engines. * * Defined as an array to prevent unexpected caching issues, it should only contain one search * engine as only one search engine will be used during a request. This might change during * testing, remember to use document_factory::clean_statics in that case. * * @var array */ protected static $docclassnames = array(); /** * Returns the appropiate document object as it depends on the engine. * * @param int $itemid Document itemid * @param string $componentname Document component name * @param string $areaname Document area name * @param \core_search\engine $engine Falls back to the search engine in use. * @return \core_search\document Base document or the engine implementation. */ public static function instance($itemid, $componentname, $areaname, $engine = false) { if ($engine === false) { $search = \core_search\manager::instance(); $engine = $search->get_engine(); } $pluginname = $engine->get_plugin_name(); if (!empty(self::$docclassnames[$pluginname])) { return new self::$docclassnames[$pluginname]($itemid, $componentname, $areaname); } self::$docclassnames[$pluginname] = $engine->get_document_classname(); return new self::$docclassnames[$pluginname]($itemid, $componentname, $areaname); } /** * Clears static vars. * * @return void */ public static function clean_static() { self::$docclassnames = array(); } } classes/manager.php000064400000233673152265255700010353 0ustar00. /** * Search subsystem manager. * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die; require_once($CFG->dirroot . '/lib/accesslib.php'); /** * Search subsystem manager. * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager { /** * @var int Text contents. */ const TYPE_TEXT = 1; /** * @var int File contents. */ const TYPE_FILE = 2; /** * @var int User can not access the document. */ const ACCESS_DENIED = 0; /** * @var int User can access the document. */ const ACCESS_GRANTED = 1; /** * @var int The document was deleted. */ const ACCESS_DELETED = 2; /** * @var int Maximum number of results that will be retrieved from the search engine. */ const MAX_RESULTS = 100; /** * @var int Number of results per page. */ const DISPLAY_RESULTS_PER_PAGE = 10; /** * @var int The id to be placed in owneruserid when there is no owner. */ const NO_OWNER_ID = 0; /** * @var float If initial query takes longer than N seconds, this will be shown in cron log. */ const DISPLAY_LONG_QUERY_TIME = 5.0; /** * @var float Adds indexing progress within one search area to cron log every N seconds. */ const DISPLAY_INDEXING_PROGRESS_EVERY = 30.0; /** * @var int Context indexing: normal priority. */ const INDEX_PRIORITY_NORMAL = 100; /** * @var int Context indexing: low priority for reindexing. */ const INDEX_PRIORITY_REINDEXING = 50; /** * @var string Core search area category for all results. */ const SEARCH_AREA_CATEGORY_ALL = 'core-all'; /** * @var string Core search area category for course content. */ const SEARCH_AREA_CATEGORY_COURSE_CONTENT = 'core-course-content'; /** * @var string Core search area category for courses. */ const SEARCH_AREA_CATEGORY_COURSES = 'core-courses'; /** * @var string Core search area category for users. */ const SEARCH_AREA_CATEGORY_USERS = 'core-users'; /** * @var string Core search area category for results that do not fit into any of existing categories. */ const SEARCH_AREA_CATEGORY_OTHER = 'core-other'; /** * @var int To avoid race conditions, do not index documents newer than this many seconds. */ const INDEXING_DELAY = 5; /** * @var \core_search\base[] Enabled search areas. */ protected static $enabledsearchareas = null; /** * @var \core_search\base[] All system search areas. */ protected static $allsearchareas = null; /** * @var \core_search\area_category[] A list of search area categories. */ protected static $searchareacategories = null; /** * @var \core_search\manager */ protected static $instance = null; /** * @var array IDs (as keys) of course deletions in progress in this requuest, if any. */ protected static $coursedeleting = []; /** * @var \core_search\engine */ protected $engine = null; /** * Note: This should be removed once possible (see MDL-60644). * * @var float Fake current time for use in PHPunit tests */ protected static $phpunitfaketime = 0; /** * @var int Result count when used with mock results for Behat tests. */ protected $behatresultcount = 0; /** * Constructor, use \core_search\manager::instance instead to get a class instance. * * @param \core_search\base The search engine to use */ public function __construct($engine) { $this->engine = $engine; } /** * @var int Record time of each successful schema check, but not more than once per 10 minutes. */ const SCHEMA_CHECK_TRACKING_DELAY = 10 * 60; /** * @var int Require a new schema check at least every 4 hours. */ const SCHEMA_CHECK_REQUIRED_EVERY = 4 * 3600; /** * Returns an initialised \core_search instance. * * While constructing the instance, checks on the search schema may be carried out. The $fast * parameter provides a way to skip those checks on pages which are used frequently. It has * no effect if an instance has already been constructed in this request. * * The $query parameter indicates that the page is used for queries rather than indexing. If * configured, this will cause the query-only search engine to be used instead of the 'normal' * one. * * @see \core_search\engine::is_installed * @see \core_search\engine::is_server_ready * @param bool $fast Set to true when calling on a page that requires high performance * @param bool $query Set true on a page that is used for querying * @throws \core_search\engine_exception * @return \core_search\manager */ public static function instance(bool $fast = false, bool $query = false) { global $CFG; // One per request, this should be purged during testing. if (static::$instance !== null) { return static::$instance; } if (empty($CFG->searchengine)) { throw new \core_search\engine_exception('enginenotselected', 'search'); } if (!$engine = static::search_engine_instance($query)) { throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine); } // Get time now and at last schema check. $now = (int)self::get_current_time(); $lastschemacheck = get_config($engine->get_plugin_name(), 'lastschemacheck'); // On pages where performance matters, tell the engine to skip schema checks. $skipcheck = false; if ($fast && $now < $lastschemacheck + self::SCHEMA_CHECK_REQUIRED_EVERY) { $skipcheck = true; $engine->skip_schema_check(); } if (!$engine->is_installed()) { throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine); } $serverstatus = $engine->is_server_ready(); if ($serverstatus !== true) { // Skip this error in Behat when faking seach results. if (!defined('BEHAT_SITE_RUNNING') || !get_config('core_search', 'behat_fakeresult')) { // Clear the record of successful schema checks since it might have failed. unset_config('lastschemacheck', $engine->get_plugin_name()); // Error message with no details as this is an exception that any user may find if the server crashes. throw new \core_search\engine_exception('engineserverstatus', 'search'); } } // If we did a successful schema check, record this, but not more than once per 10 minutes // (to avoid updating the config db table/cache too often in case it gets called frequently). if (!$skipcheck && $now >= $lastschemacheck + self::SCHEMA_CHECK_TRACKING_DELAY) { set_config('lastschemacheck', $now, $engine->get_plugin_name()); } static::$instance = new \core_search\manager($engine); return static::$instance; } /** * Returns whether global search is enabled or not. * * @return bool */ public static function is_global_search_enabled() { global $CFG; return !empty($CFG->enableglobalsearch); } /** * Tests if global search is configured to be equivalent to the front page course search. * * @return bool */ public static function can_replace_course_search(): bool { global $CFG; // Assume we can replace front page search. $canreplace = true; // Global search must be enabled. if (!static::is_global_search_enabled()) { $canreplace = false; } // Users must be able to search the details of all courses that they can see, // even if they do not have access to them. if (empty($CFG->searchincludeallcourses)) { $canreplace = false; } // Course search must be enabled. if ($canreplace) { $areaid = static::generate_areaid('core_course', 'course'); $enabledareas = static::get_search_areas_list(true); $canreplace = isset($enabledareas[$areaid]); } return $canreplace; } /** * Returns the search URL for course search * * @return moodle_url */ public static function get_course_search_url() { if (self::can_replace_course_search()) { $searchurl = '/search/index.php'; } else { $searchurl = '/course/search.php'; } return new \moodle_url($searchurl); } /** * Returns whether indexing is enabled or not (you can enable indexing even when search is not * enabled at the moment, so as to have it ready for students). * * @return bool True if indexing is enabled. */ public static function is_indexing_enabled() { global $CFG; return !empty($CFG->enableglobalsearch) || !empty($CFG->searchindexwhendisabled); } /** * Returns an instance of the search engine. * * @param bool $query If true, gets the query-only search engine (where configured) * @return \core_search\engine */ public static function search_engine_instance(bool $query = false) { global $CFG; if ($query && $CFG->searchenginequeryonly) { return self::search_engine_instance_from_setting($CFG->searchenginequeryonly); } else { return self::search_engine_instance_from_setting($CFG->searchengine); } } /** * Loads a search engine based on the name given in settings, which can optionally * include '-alternate' to indicate that an alternate version should be used. * * @param string $setting * @return engine|null */ protected static function search_engine_instance_from_setting(string $setting): ?engine { if (preg_match('~^(.*)-alternate$~', $setting, $matches)) { $enginename = $matches[1]; $alternate = true; } else { $enginename = $setting; $alternate = false; } $classname = '\\search_' . $enginename . '\\engine'; if (!class_exists($classname)) { return null; } if ($alternate) { return new $classname(true); } else { // Use the constructor with no parameters for compatibility. return new $classname(); } } /** * Returns the search engine. * * @return \core_search\engine */ public function get_engine() { return $this->engine; } /** * Returns a search area class name. * * @param string $areaid * @return string */ protected static function get_area_classname($areaid) { list($componentname, $areaname) = static::extract_areaid_parts($areaid); return '\\' . $componentname . '\\search\\' . $areaname; } /** * Returns a new area search indexer instance. * * @param string $areaid * @return \core_search\base|bool False if the area is not available. */ public static function get_search_area($areaid) { // We have them all here. if (!empty(static::$allsearchareas[$areaid])) { return static::$allsearchareas[$areaid]; } $classname = static::get_area_classname($areaid); if (class_exists($classname) && static::is_search_area($classname)) { return new $classname(); } return false; } /** * Return the list of available search areas. * * @param bool $enabled Return only the enabled ones. * @return \core_search\base[] */ public static function get_search_areas_list($enabled = false) { // Two different arrays, we don't expect these arrays to be big. if (static::$allsearchareas !== null) { if (!$enabled) { return static::$allsearchareas; } else { return static::$enabledsearchareas; } } static::$allsearchareas = array(); static::$enabledsearchareas = array(); $searchclasses = \core_component::get_component_classes_in_namespace(null, 'search'); foreach ($searchclasses as $classname => $classpath) { $areaname = substr(strrchr($classname, '\\'), 1); $componentname = strstr($classname, '\\', 1); if (!static::is_search_area($classname)) { continue; } $areaid = static::generate_areaid($componentname, $areaname); $searchclass = new $classname(); static::$allsearchareas[$areaid] = $searchclass; if ($searchclass->is_enabled()) { static::$enabledsearchareas[$areaid] = $searchclass; } } if ($enabled) { return static::$enabledsearchareas; } return static::$allsearchareas; } /** * Return search area category instance by category name. * * @param string $name Category name. If name is not valid will return default category. * * @return \core_search\area_category */ public static function get_search_area_category_by_name($name) { if (key_exists($name, self::get_search_area_categories())) { return self::get_search_area_categories()[$name]; } else { return self::get_search_area_categories()[self::get_default_area_category_name()]; } } /** * Return a list of existing search area categories. * * @return \core_search\area_category[] */ public static function get_search_area_categories() { if (!isset(static::$searchareacategories)) { $categories = self::get_core_search_area_categories(); // Go through all existing search areas and get categories they are assigned to. $areacategories = []; foreach (self::get_search_areas_list() as $searcharea) { foreach ($searcharea->get_category_names() as $categoryname) { if (!key_exists($categoryname, $areacategories)) { $areacategories[$categoryname] = []; } $areacategories[$categoryname][] = $searcharea; } } // Populate core categories by areas. foreach ($areacategories as $name => $searchareas) { if (key_exists($name, $categories)) { $categories[$name]->set_areas($searchareas); } else { throw new \coding_exception('Unknown core search area category ' . $name); } } // Get additional categories. $additionalcategories = self::get_additional_search_area_categories(); foreach ($additionalcategories as $additionalcategory) { if (!key_exists($additionalcategory->get_name(), $categories)) { $categories[$additionalcategory->get_name()] = $additionalcategory; } } // Remove categories without areas. foreach ($categories as $key => $category) { if (empty($category->get_areas())) { unset($categories[$key]); } } // Sort categories by order. uasort($categories, function($category1, $category2) { return $category1->get_order() <=> $category2->get_order(); }); static::$searchareacategories = $categories; } return static::$searchareacategories; } /** * Get list of core search area categories. * * @return \core_search\area_category[] */ protected static function get_core_search_area_categories() { $categories = []; $categories[self::SEARCH_AREA_CATEGORY_ALL] = new area_category( self::SEARCH_AREA_CATEGORY_ALL, get_string('core-all', 'search'), 0, self::get_search_areas_list(true) ); $categories[self::SEARCH_AREA_CATEGORY_COURSE_CONTENT] = new area_category( self::SEARCH_AREA_CATEGORY_COURSE_CONTENT, get_string('core-course-content', 'search'), 1 ); $categories[self::SEARCH_AREA_CATEGORY_COURSES] = new area_category( self::SEARCH_AREA_CATEGORY_COURSES, get_string('core-courses', 'search'), 2 ); $categories[self::SEARCH_AREA_CATEGORY_USERS] = new area_category( self::SEARCH_AREA_CATEGORY_USERS, get_string('core-users', 'search'), 3 ); $categories[self::SEARCH_AREA_CATEGORY_OTHER] = new area_category( self::SEARCH_AREA_CATEGORY_OTHER, get_string('core-other', 'search'), 4 ); return $categories; } /** * Gets a list of additional search area categories. * * @return \core_search\area_category[] */ protected static function get_additional_search_area_categories() { $additionalcategories = []; // Allow plugins to add custom search area categories. if ($pluginsfunction = get_plugins_with_function('search_area_categories')) { foreach ($pluginsfunction as $plugintype => $plugins) { foreach ($plugins as $pluginfunction) { $plugincategories = $pluginfunction(); // We're expecting a list of valid area categories. if (is_array($plugincategories)) { foreach ($plugincategories as $plugincategory) { if (self::is_valid_area_category($plugincategory)) { $additionalcategories[] = $plugincategory; } else { throw new \coding_exception('Invalid search area category!'); } } } else { throw new \coding_exception($pluginfunction . ' should return a list of search area categories!'); } } } } return $additionalcategories; } /** * Check if provided instance of area category is valid. * * @param mixed $areacategory Area category instance. Potentially could be anything. * * @return bool */ protected static function is_valid_area_category($areacategory) { return $areacategory instanceof area_category; } /** * Clears all static caches. * * @return void */ public static function clear_static() { static::$enabledsearchareas = null; static::$allsearchareas = null; static::$instance = null; static::$searchareacategories = null; static::$coursedeleting = []; static::$phpunitfaketime = null; base_block::clear_static(); engine::clear_users_cache(); } /** * Generates an area id from the componentname and the area name. * * There should not be any naming conflict as the area name is the * class name in component/classes/search/. * * @param string $componentname * @param string $areaname * @return void */ public static function generate_areaid($componentname, $areaname) { return $componentname . '-' . $areaname; } /** * Returns all areaid string components (component name and area name). * * @param string $areaid * @return array Component name (Frankenstyle) and area name (search area class name) */ public static function extract_areaid_parts($areaid) { return explode('-', $areaid); } /** * Parse a search area id and get plugin name and config name prefix from it. * * @param string $areaid Search area id. * @return array Where the first element is a plugin name and the second is config names prefix. */ public static function parse_areaid($areaid) { $parts = self::extract_areaid_parts($areaid); if (empty($parts[1])) { throw new \coding_exception('Trying to parse invalid search area id ' . $areaid); } $component = $parts[0]; $area = $parts[1]; if (strpos($component, 'core') === 0) { $plugin = 'core_search'; $configprefix = str_replace('-', '_', $areaid); } else { $plugin = $component; $configprefix = 'search_' . $area; } return [$plugin, $configprefix]; } /** * Returns information about the areas which the user can access. * * The returned value is a stdClass object with the following fields: * - everything (bool, true for admin only) * - usercontexts (indexed by area identifier then context * - separategroupscontexts (contexts within which group restrictions apply) * - visiblegroupscontextsareas (overrides to the above when the same contexts also have * 'visible groups' for certain search area ids - hopefully rare) * - usergroups (groups which the current user belongs to) * * The areas can be limited by course id and context id. If specifying context ids, results * are limited to the exact context ids specified and not their children (for example, giving * the course context id would result in including search items with the course context id, and * not anything from a context inside the course). For performance, you should also specify * course id(s) when using context ids. * * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. * @param array|false $limitcontextids An array of context ids to limit the search to. False for no limiting. * @return \stdClass Object as described above */ protected function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) { global $DB, $USER; // All results for admins (unless they have chosen to limit results). Eventually we could // add a new capability for managers. if (is_siteadmin() && !$limitcourseids && !$limitcontextids) { return (object)array('everything' => true); } $areasbylevel = array(); // Split areas by context level so we only iterate only once through courses and cms. $searchareas = static::get_search_areas_list(true); foreach ($searchareas as $areaid => $unused) { $classname = static::get_area_classname($areaid); $searcharea = new $classname(); foreach ($classname::get_levels() as $level) { $areasbylevel[$level][$areaid] = $searcharea; } } // This will store area - allowed contexts relations. $areascontexts = array(); // Initialise two special-case arrays for storing other information related to the contexts. $separategroupscontexts = array(); $visiblegroupscontextsareas = array(); $usergroups = array(); if (empty($limitcourseids) && !empty($areasbylevel[CONTEXT_SYSTEM])) { // We add system context to all search areas working at this level. Here each area is fully responsible of // the access control as we can not automate much, we can not even check guest access as some areas might // want to allow guests to retrieve data from them. $systemcontextid = \context_system::instance()->id; if (!$limitcontextids || in_array($systemcontextid, $limitcontextids)) { foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) { $areascontexts[$areaid][$systemcontextid] = $systemcontextid; } } } if (!empty($areasbylevel[CONTEXT_USER])) { if ($usercontext = \context_user::instance($USER->id, IGNORE_MISSING)) { if (!$limitcontextids || in_array($usercontext->id, $limitcontextids)) { // Extra checking although only logged users should reach this point, guest users have a valid context id. foreach ($areasbylevel[CONTEXT_USER] as $areaid => $searchclass) { $areascontexts[$areaid][$usercontext->id] = $usercontext->id; } } } } if (is_siteadmin()) { $allcourses = $this->get_all_courses($limitcourseids); } else { $allcourses = $mycourses = $this->get_my_courses((bool)get_config('core', 'searchallavailablecourses')); if (self::include_all_courses()) { $allcourses = $this->get_all_courses($limitcourseids); } } if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) { $allcourses[SITEID] = get_course(SITEID); if (isset($mycourses)) { $mycourses[SITEID] = get_course(SITEID); } } // Keep a list of included course context ids (needed for the block calculation below). $coursecontextids = []; $modulecms = []; foreach ($allcourses as $course) { if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) { // Skip non-included courses. continue; } $coursecontext = \context_course::instance($course->id); $hasgrouprestrictions = false; if (!empty($areasbylevel[CONTEXT_COURSE]) && (!$limitcontextids || in_array($coursecontext->id, $limitcontextids))) { // Add the course contexts the user can view. foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) { if (!empty($mycourses[$course->id]) || \core_course_category::can_view_course_info($course)) { $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id; } } } // Skip module context if a user can't access related course. if (isset($mycourses) && !key_exists($course->id, $mycourses)) { continue; } $coursecontextids[] = $coursecontext->id; // Info about the course modules. $modinfo = get_fast_modinfo($course); if (!empty($areasbylevel[CONTEXT_MODULE])) { // Add the module contexts the user can view (cm_info->uservisible). foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) { // Removing the plugintype 'mod_' prefix. $modulename = substr($searchclass->get_component_name(), 4); $modinstances = $modinfo->get_instances_of($modulename); foreach ($modinstances as $modinstance) { // Skip module context if not included in list of context ids. if ($limitcontextids && !in_array($modinstance->context->id, $limitcontextids)) { continue; } if ($modinstance->uservisible) { $contextid = $modinstance->context->id; $areascontexts[$areaid][$contextid] = $contextid; $modulecms[$modinstance->id] = $modinstance; if (!has_capability('moodle/site:accessallgroups', $modinstance->context) && ($searchclass instanceof base_mod) && $searchclass->supports_group_restriction()) { if ($searchclass->restrict_cm_access_by_group($modinstance)) { $separategroupscontexts[$contextid] = $contextid; $hasgrouprestrictions = true; } else { // Track a list of anything that has a group id (so might get // filtered) and doesn't want to be, in this context. if (!array_key_exists($contextid, $visiblegroupscontextsareas)) { $visiblegroupscontextsareas[$contextid] = array(); } $visiblegroupscontextsareas[$contextid][$areaid] = $areaid; } } } } } } // Insert group information for course (unless there aren't any modules restricted by // group for this user in this course, in which case don't bother). if ($hasgrouprestrictions) { $groups = groups_get_all_groups($course->id, $USER->id, 0, 'g.id'); foreach ($groups as $group) { $usergroups[$group->id] = $group->id; } } } // Chuck away all the 'visible groups contexts' data unless there is actually something // that does use separate groups in the same context (this data is only used as an // 'override' in cases where the search is restricting to separate groups). foreach ($visiblegroupscontextsareas as $contextid => $areas) { if (!array_key_exists($contextid, $separategroupscontexts)) { unset($visiblegroupscontextsareas[$contextid]); } } // Add all supported block contexts for course contexts that user can access, in a single query for performance. if (!empty($areasbylevel[CONTEXT_BLOCK]) && !empty($coursecontextids)) { // Get list of all block types we care about. $blocklist = []; foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { $blocklist[$searchclass->get_block_name()] = true; } list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist)); // Get list of course contexts. list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids); // Get list of block context (if limited). $blockcontextwhere = ''; $blockcontextparams = []; if ($limitcontextids) { list ($blockcontextsql, $blockcontextparams) = $DB->get_in_or_equal($limitcontextids); $blockcontextwhere = 'AND x.id ' . $blockcontextsql; } // Query all blocks that are within an included course, and are set to be visible, and // in a supported page type (basically just course view). This query could be // extended (or a second query added) to support blocks that are within a module // context as well, and we could add more page types if required. $blockrecs = $DB->get_records_sql(" SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id AND bp.contextid = bi.parentcontextid AND bp.pagetype LIKE 'course-view-%' AND bp.subpage = '' AND bp.visible = 0 WHERE bi.parentcontextid $contextsql $blockcontextwhere AND bi.blockname $blocknamesql AND bi.subpagepattern IS NULL AND (bi.pagetypepattern = 'site-index' OR bi.pagetypepattern LIKE 'course-view-%' OR bi.pagetypepattern = 'course-*' OR bi.pagetypepattern = '*') AND bp.id IS NULL", array_merge([CONTEXT_BLOCK], $contextparams, $blockcontextparams, $blocknameparams)); $blockcontextsbyname = []; foreach ($blockrecs as $blockrec) { if (empty($blockcontextsbyname[$blockrec->blockname])) { $blockcontextsbyname[$blockrec->blockname] = []; } \context_helper::preload_from_record($blockrec); $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance( $blockrec->blockinstanceid); } // Add the block contexts the user can view. foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { if (empty($blockcontextsbyname[$searchclass->get_block_name()])) { continue; } foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) { if (has_capability('moodle/block:view', $context)) { $areascontexts[$areaid][$context->id] = $context->id; } } } } // Return all the data. return (object)array('everything' => false, 'usercontexts' => $areascontexts, 'separategroupscontexts' => $separategroupscontexts, 'usergroups' => $usergroups, 'visiblegroupscontextsareas' => $visiblegroupscontextsareas); } /** * Returns requested page of documents plus additional information for paging. * * This function does not perform any kind of security checking for access, the caller code * should check that the current user have moodle/search:query capability. * * If a page is requested that is beyond the last result, the last valid page is returned in * results, and actualpage indicates which page was returned. * * @param stdClass $formdata * @param int $pagenum The 0 based page number. * @return object An object with 3 properties: * results => An array of \core_search\documents for the actual page. * totalcount => Number of records that are possibly available, to base paging on. * actualpage => The actual page returned. */ public function paged_search(\stdClass $formdata, $pagenum) { $out = new \stdClass(); if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { $cat = self::get_search_area_category_by_name($formdata->cat); if (empty($formdata->areaids)) { $formdata->areaids = array_keys($cat->get_areas()); } else { foreach ($formdata->areaids as $key => $areaid) { if (!key_exists($areaid, $cat->get_areas())) { unset($formdata->areaids[$key]); } } } } $perpage = static::DISPLAY_RESULTS_PER_PAGE; // Make sure we only allow request up to max page. $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1); // Calculate the first and last document number for the current page, 1 based. $mindoc = ($pagenum * $perpage) + 1; $maxdoc = ($pagenum + 1) * $perpage; // Get engine documents, up to max. $docs = $this->search($formdata, $maxdoc); $resultcount = count($docs); if ($resultcount < $maxdoc) { // This means it couldn't give us results to max, so the count must be the max. $out->totalcount = $resultcount; } else { // Get the possible count reported by engine, and limit to our max. $out->totalcount = $this->engine->get_query_total_count(); if (defined('BEHAT_SITE_RUNNING') && $this->behatresultcount) { // Override results when using Behat mock results. $out->totalcount = $this->behatresultcount; } $out->totalcount = min($out->totalcount, static::MAX_RESULTS); } // Determine the actual page. if ($resultcount < $mindoc) { // We couldn't get the min docs for this page, so determine what page we can get. $out->actualpage = floor(($resultcount - 1) / $perpage); } else { $out->actualpage = $pagenum; } // Split the results to only return the page. $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true); return $out; } /** * Returns documents from the engine based on the data provided. * * This function does not perform any kind of security checking, the caller code * should check that the current user have moodle/search:query capability. * * It might return the results from the cache instead. * * Valid formdata options include: * - q (query text) * - courseids (optional list of course ids to restrict) * - contextids (optional list of context ids to restrict) * - context (Moodle context object for location user searched from) * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance') * - userids (optional list of user ids to restrict) * * @param \stdClass $formdata Query input data (usually from search form) * @param int $limit The maximum number of documents to return * @return \core_search\document[] */ public function search(\stdClass $formdata, $limit = 0) { // For Behat testing, the search results can be faked using a special step. if (defined('BEHAT_SITE_RUNNING')) { $fakeresult = get_config('core_search', 'behat_fakeresult'); if ($fakeresult) { // Clear config setting. unset_config('core_search', 'behat_fakeresult'); // Check query matches expected value. $details = json_decode($fakeresult); if ($formdata->q !== $details->query) { throw new \coding_exception('Unexpected search query: ' . $formdata->q); } // Create search documents from the JSON data. $docs = []; foreach ($details->results as $result) { $doc = new \core_search\document($result->itemid, $result->componentname, $result->areaname); foreach ((array)$result->fields as $field => $value) { $doc->set($field, $value); } foreach ((array)$result->extrafields as $field => $value) { $doc->set_extra($field, $value); } $area = $this->get_search_area($doc->get('areaid')); $doc->set_doc_url($area->get_doc_url($doc)); $doc->set_context_url($area->get_context_url($doc)); $docs[] = $doc; } // Store the mock count, and apply the limit to the returned results. $this->behatresultcount = count($docs); if ($this->behatresultcount > $limit) { $docs = array_slice($docs, 0, $limit); } return $docs; } } $limitcourseids = $this->build_limitcourseids($formdata); $limitcontextids = false; if (!empty($formdata->contextids)) { $limitcontextids = $formdata->contextids; } // Clears previous query errors. $this->engine->clear_query_error(); $contextinfo = $this->get_areas_user_accesses($limitcourseids, $limitcontextids); if (!$contextinfo->everything && !$contextinfo->usercontexts) { // User can not access any context. $docs = array(); } else { // If engine does not support groups, remove group information from the context info - // use the old format instead (true = admin, array = user contexts). if (!$this->engine->supports_group_filtering()) { $contextinfo = $contextinfo->everything ? true : $contextinfo->usercontexts; } // Execute the actual query. $docs = $this->engine->execute_query($formdata, $contextinfo, $limit); } return $docs; } /** * Search for top ranked result. * @param \stdClass $formdata search query data * @return array|document[] */ public function search_top(\stdClass $formdata): array { global $USER; // Return if the config value is set to 0. $maxtopresult = get_config('core', 'searchmaxtopresults'); if (empty($maxtopresult)) { return []; } // Only process if 'searchenablecategories' is set. if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { $cat = self::get_search_area_category_by_name($formdata->cat); $formdata->areaids = array_keys($cat->get_areas()); } else { return []; } $docs = $this->search($formdata); // Look for course, teacher and course content. $coursedocs = []; $courseteacherdocs = []; $coursecontentdocs = []; $otherdocs = []; foreach ($docs as $doc) { if ($doc->get('areaid') === 'core_course-course' && stripos($doc->get('title'), $formdata->q) !== false) { $coursedocs[] = $doc; } else if (strpos($doc->get('areaid'), 'course_teacher') !== false && stripos($doc->get('content'), $formdata->q) !== false) { $courseteacherdocs[] = $doc; } else if (strpos($doc->get('areaid'), 'mod_') !== false) { $coursecontentdocs[] = $doc; } else { $otherdocs[] = $doc; } } // Swap current courses to top. $enroledcourses = $this->get_my_courses(false); // Move current courses of the user to top. foreach ($enroledcourses as $course) { $completion = new \completion_info($course); if (!$completion->is_course_complete($USER->id)) { foreach ($coursedocs as $index => $doc) { $areaid = $doc->get('areaid'); if ($areaid == 'core_course-course' && $course->id == $doc->get('courseid')) { unset($coursedocs[$index]); array_unshift($coursedocs, $doc); } } } } $maxtopresult = get_config('core', 'searchmaxtopresults'); $result = array_merge($coursedocs, $courseteacherdocs, $coursecontentdocs, $otherdocs); return array_slice($result, 0, $maxtopresult); } /** * Build a list of course ids to limit the search based on submitted form data. * * @param \stdClass $formdata Submitted search form data. * * @return array|bool */ protected function build_limitcourseids(\stdClass $formdata) { $limitcourseids = false; if (!empty($formdata->mycoursesonly)) { $limitcourseids = array_keys($this->get_my_courses(false)); } if (!empty($formdata->courseids)) { if (empty($limitcourseids)) { $limitcourseids = $formdata->courseids; } else { $limitcourseids = array_intersect($limitcourseids, $formdata->courseids); } } return $limitcourseids; } /** * Merge separate index segments into one. */ public function optimize_index() { $this->engine->optimize(); } /** * Index all documents. * * @param bool $fullindex Whether we should reindex everything or not. * @param float $timelimit Time limit in seconds (0 = no time limit) * @param \progress_trace|null $progress Optional class for tracking progress * @throws \moodle_exception * @return bool Whether there was any updated document or not. */ public function index($fullindex = false, $timelimit = 0, ?\progress_trace $progress = null) { global $DB, $CFG; // Cannot combine time limit with reindex. if ($timelimit && $fullindex) { throw new \coding_exception('Cannot apply time limit when reindexing'); } if (!$progress) { $progress = new \null_progress_trace(); } // Unlimited time. \core_php_time_limit::raise(); // Notify the engine that an index starting. $this->engine->index_starting($fullindex); $sumdocs = 0; $searchareas = $this->get_search_areas_list(true); if ($timelimit) { // If time is limited (and therefore we're not just indexing everything anyway), select // an order for search areas. The intention here is to avoid a situation where a new // large search area is enabled, and this means all our other search areas go out of // date while that one is being indexed. To do this, we order by the time we spent // indexing them last time we ran, meaning anything that took a very long time will be // done last. uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) { return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration(); }); // Decide time to stop. $stopat = self::get_current_time() + $timelimit; } // Work out if we are in test mode, in which case we disable the indexing delay (because // the normal pattern is to add a document and immediately index it). $testmode = (PHPUNIT_TEST || defined('BEHAT_TEST')) && empty($CFG->searchindexingdelayfortestscript); foreach ($searchareas as $areaid => $searcharea) { $progress->output('Processing area: ' . $searcharea->get_visible_name()); // Notify the engine that an area is starting. $this->engine->area_index_starting($searcharea, $fullindex); $indexingstart = (int)self::get_current_time(); $elapsed = self::get_current_time(); // This is used to store this component config. list($componentconfigname, $varname) = $searcharea->get_config_var_name(); $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart')); // The effective start time of previous indexing was some seconds earlier because we // only index data up to that time, to avoid race conditions (if it takes a while to // write a document to the database and the timecreated for that document ends up being // a second or two out of date). This mechanism is disabled for tests. if (!$testmode) { // The -1 here is because for example, if _indexingstart is 123, we will have // indexed everything up to 123 - 5 = 118 (inclusive). So next time, we can start // at 119 = 123 - 4 and we don't have to repeat 118. $prevtimestart -= (self::INDEXING_DELAY - 1); } if ($fullindex === true) { $referencestarttime = 0; // For full index, we delete any queued context index requests, as those will // obviously be met by the full index. $DB->delete_records('search_index_requests'); } else { $partial = get_config($componentconfigname, $varname . '_partial'); if ($partial) { // When the previous index did not complete all data, we start from the time of the // last document that was successfully indexed. (Note this will result in // re-indexing that one document, but we can't avoid that because there may be // other documents in the same second.) $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun')); } else { $referencestarttime = $prevtimestart; } } // Getting the recordset from the area. $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime); $initialquerytime = self::get_current_time() - $elapsed; if ($initialquerytime > self::DISPLAY_LONG_QUERY_TIME) { $progress->output('Initial query took ' . round($initialquerytime, 1) . ' seconds.', 1); } // Pass get_document as callback. $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing(); $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart); if ($timelimit) { $options['stopat'] = $stopat; } $options['progress'] = $progress; // Skip 'future' documents, also any written very recently (to avoid race conditions). // The exception is for PHPunit and Behat (step 'I update the global search index') // where we allow it to index recent documents as well, we don't want it to have to wait. $iterator = new skip_future_documents_iterator( new \core\dml\recordset_walk($recordset, [$searcharea, 'get_document'], $options), $indexingstart - ($testmode ? 0 : self::INDEXING_DELAY), ); $result = $this->engine->add_documents($iterator, $searcharea, $options); $recordset->close(); $batchinfo = ''; if (count($result) === 6) { [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; // Only show the batch count if we actually batched any requests. if ($batches !== $numdocs + $numdocsignored) { $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; } } else { throw new \coding_exception('engine::add_documents() should return 6 values'); } if ($numdocs > 0) { $elapsed = round((self::get_current_time() - $elapsed), 1); $partialtext = ''; if ($partial) { $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')) . ')'; } $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . $partialtext . '.', 1); } else { $progress->output('No new documents to index.', 1); } // Notify the engine this area is complete, and only mark times if true. if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) { $sumdocs += $numdocs; // Store last index run once documents have been committed to the search engine. set_config($varname . '_indexingstart', $indexingstart, $componentconfigname); set_config($varname . '_indexingend', (int)self::get_current_time(), $componentconfigname); set_config($varname . '_docsignored', $numdocsignored, $componentconfigname); set_config($varname . '_docsprocessed', $numdocs, $componentconfigname); set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname); if ($lastindexeddoc > 0) { set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname); } if ($partial) { set_config($varname . '_partial', 1, $componentconfigname); } else { unset_config($varname . '_partial', $componentconfigname); } } else { $progress->output('Engine reported error.'); } if ($timelimit && (self::get_current_time() >= $stopat)) { $progress->output('Stopping indexing due to time limit.'); break; } } if ($sumdocs > 0) { $event = \core\event\search_indexed::create( array('context' => \context_system::instance())); $event->trigger(); } $this->engine->index_complete($sumdocs, $fullindex); return (bool)$sumdocs; } /** * Indexes or reindexes a specific context of the system, e.g. one course. * * The function returns an object with field 'complete' (true or false). * * This function supports partial indexing via the time limit parameter. If the time limit * expires, it will return values for $startfromarea and $startfromtime which can be passed * next time to continue indexing. * * @param \context $context Context to restrict index. * @param string $singleareaid If specified, indexes only the given area. * @param float $timelimit Time limit in seconds (0 = no time limit) * @param \progress_trace|null $progress Optional class for tracking progress * @param string $startfromarea Area to start from * @param int $startfromtime Timestamp to start from * @return \stdClass Object indicating success */ public function index_context($context, $singleareaid = '', $timelimit = 0, ?\progress_trace $progress = null, $startfromarea = '', $startfromtime = 0) { if (!$progress) { $progress = new \null_progress_trace(); } // Work out time to stop, if limited. if ($timelimit) { // Decide time to stop. $stopat = self::get_current_time() + $timelimit; } // No PHP time limit. \core_php_time_limit::raise(); // Notify the engine that an index starting. $this->engine->index_starting(false); $sumdocs = 0; // Get all search areas, in consistent order. $searchareas = $this->get_search_areas_list(true); ksort($searchareas); // Are we skipping past some that were handled previously? $skipping = $startfromarea ? true : false; foreach ($searchareas as $areaid => $searcharea) { // If we're only processing one area id, skip all the others. if ($singleareaid && $singleareaid !== $areaid) { continue; } // If we're skipping to a later area, continue through the loop. $referencestarttime = 0; if ($skipping) { if ($areaid !== $startfromarea) { continue; } // Stop skipping and note the reference start time. $skipping = false; $referencestarttime = $startfromtime; } $progress->output('Processing area: ' . $searcharea->get_visible_name()); $elapsed = self::get_current_time(); // Get the recordset of all documents from the area for this context. $recordset = $searcharea->get_document_recordset($referencestarttime, $context); if (!$recordset) { if ($recordset === null) { $progress->output('Skipping (not relevant to context).', 1); } else { $progress->output('Skipping (does not support context indexing).', 1); } continue; } // Notify the engine that an area is starting. $this->engine->area_index_starting($searcharea, false); // Work out search options. $options = []; $options['indexfiles'] = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing(); if ($timelimit) { $options['stopat'] = $stopat; } // Construct iterator which will use get_document on the recordset results. $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'), $options); // Use this iterator to add documents. $result = $this->engine->add_documents($iterator, $searcharea, $options); $batchinfo = ''; if (count($result) === 6) { [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; // Only show the batch count if we actually batched any requests. if ($batches !== $numdocs + $numdocsignored) { $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; } } else { throw new \coding_exception('engine::add_documents() should return 6 values'); } if ($numdocs > 0) { $elapsed = round((self::get_current_time() - $elapsed), 3); $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . ($partial ? ' (not complete)' : '') . '.', 1); } else { $progress->output('No documents to index.', 1); } // Notify the engine this area is complete, but don't store any times as this is not // part of the 'normal' search index. if (!$this->engine->area_index_complete($searcharea, $numdocs, false)) { $progress->output('Engine reported error.', 1); } if ($partial && $timelimit && (self::get_current_time() >= $stopat)) { $progress->output('Stopping indexing due to time limit.'); break; } } if ($sumdocs > 0) { $event = \core\event\search_indexed::create( array('context' => $context)); $event->trigger(); } $this->engine->index_complete($sumdocs, false); // Indicate in result whether we completed indexing, or only part of it. $result = new \stdClass(); if ($partial) { $result->complete = false; $result->startfromarea = $areaid; $result->startfromtime = $lastindexeddoc; } else { $result->complete = true; } return $result; } /** * Resets areas config. * * @throws \moodle_exception * @param string $areaid * @return void */ public function reset_config($areaid = false) { if (!empty($areaid)) { $searchareas = array(); if (!$searchareas[$areaid] = static::get_search_area($areaid)) { throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid); } } else { // Only the enabled ones. $searchareas = static::get_search_areas_list(true); } foreach ($searchareas as $searcharea) { list($componentname, $varname) = $searcharea->get_config_var_name(); $config = $searcharea->get_config(); foreach ($config as $key => $value) { // We reset them all but the enable/disabled one. if ($key !== $varname . '_enabled') { set_config($key, 0, $componentname); } } } } /** * Deletes an area's documents or all areas documents. * * @param string $areaid The area id or false for all * @return void */ public function delete_index($areaid = false) { if (!empty($areaid)) { $this->engine->delete($areaid); $this->reset_config($areaid); } else { $this->engine->delete(); $this->reset_config(); } } /** * Deletes index by id. * * @param int Solr Document string $id */ public function delete_index_by_id($id) { $this->engine->delete_by_id($id); } /** * Returns search areas configuration. * * @param \core_search\base[] $searchareas * @return \stdClass[] $configsettings */ public function get_areas_config($searchareas) { $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored', 'docsprocessed', 'recordsprocessed', 'partial'); $configsettings = []; foreach ($searchareas as $searcharea) { $areaid = $searcharea->get_area_id(); $configsettings[$areaid] = new \stdClass(); list($componentname, $varname) = $searcharea->get_config_var_name(); if (!$searcharea->is_enabled()) { // We delete all indexed data on disable so no info. foreach ($vars as $var) { $configsettings[$areaid]->{$var} = 0; } } else { foreach ($vars as $var) { $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var); } } // Formatting the time. if (!empty($configsettings[$areaid]->lastindexrun)) { $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun); } else { $configsettings[$areaid]->lastindexrun = get_string('never'); } } return $configsettings; } /** * Triggers search_results_viewed event * * Other data required: * - q: The query string * - page: The page number * - title: Title filter * - areaids: Search areas filter * - courseids: Courses filter * - timestart: Time start filter * - timeend: Time end filter * * @since Moodle 3.2 * @param array $other Other info for the event. * @return \core\event\search_results_viewed */ public static function trigger_search_results_viewed($other) { $event = \core\event\search_results_viewed::create([ 'context' => \context_system::instance(), 'other' => $other ]); $event->trigger(); return $event; } /** * Checks whether a classname is of an actual search area. * * @param string $classname * @return bool */ protected static function is_search_area($classname) { if (is_subclass_of($classname, 'core_search\base')) { return (new \ReflectionClass($classname))->isInstantiable(); } return false; } /** * Requests that a specific context is indexed by the scheduled task. The context will be * added to a queue which is processed by the task. * * This is used after a restore to ensure that restored items are indexed, even though their * modified time will be older than the latest indexed. It is also used by the 'Gradual reindex' * admin feature from the search areas screen. * * @param \context $context Context to index within * @param string $areaid Area to index, '' = all areas * @param int $priority Priority (INDEX_PRIORITY_xx constant) */ public static function request_index(\context $context, $areaid = '', $priority = self::INDEX_PRIORITY_NORMAL) { global $DB; // Check through existing requests for this context or any parent context. list ($contextsql, $contextparams) = $DB->get_in_or_equal( $context->get_parent_context_ids(true)); $existing = $DB->get_records_select('search_index_requests', 'contextid ' . $contextsql, $contextparams, '', 'id, searcharea, partialarea, indexpriority'); foreach ($existing as $rec) { // If we haven't started processing the existing request yet, and it covers the same // area (or all areas) then that will be sufficient so don't add anything else. if ($rec->partialarea === '' && ($rec->searcharea === $areaid || $rec->searcharea === '')) { // If the existing request has the same (or higher) priority, no need to add anything. if ($rec->indexpriority >= $priority) { return; } // The existing request has lower priority. If it is exactly the same, then just // adjust the priority of the existing request. if ($rec->searcharea === $areaid) { $DB->set_field('search_index_requests', 'indexpriority', $priority, ['id' => $rec->id]); return; } // The existing request would cover this area but is a lower priority. We need to // add the new request even though that means we will index part of it twice. } } // No suitable existing request, so add a new one. $newrecord = [ 'contextid' => $context->id, 'searcharea' => $areaid, 'timerequested' => (int)self::get_current_time(), 'partialarea' => '', 'partialtime' => 0, 'indexpriority' => $priority ]; $DB->insert_record('search_index_requests', $newrecord); } /** * Processes outstanding index requests. This will take the first item from the queue (taking * account the indexing priority) and process it, continuing until an optional time limit is * reached. * * If there are no index requests, the function will do nothing. * * @param float $timelimit Time limit (0 = none) * @param \progress_trace|null $progress Optional progress indicator */ public function process_index_requests($timelimit = 0.0, ?\progress_trace $progress = null) { global $DB; if (!$progress) { $progress = new \null_progress_trace(); } $before = self::get_current_time(); if ($timelimit) { $stopat = $before + $timelimit; } while (true) { // Retrieve first request, using fully defined ordering. $requests = $DB->get_records('search_index_requests', null, 'indexpriority DESC, timerequested, contextid, searcharea', 'id, contextid, searcharea, partialarea, partialtime', 0, 1); if (!$requests) { // If there are no more requests, stop. break; } $request = reset($requests); // Calculate remaining time. $remainingtime = 0; $beforeindex = self::get_current_time(); if ($timelimit) { $remainingtime = $stopat - $beforeindex; // If the time limit expired already, stop now. (Otherwise we might accidentally // index with no time limit or a negative time limit.) if ($remainingtime <= 0) { break; } } // Show a message before each request, indicating what will be indexed. $context = \context::instance_by_id($request->contextid, IGNORE_MISSING); if (!$context) { $DB->delete_records('search_index_requests', ['id' => $request->id]); $progress->output('Skipped deleted context: ' . $request->contextid); continue; } $contextname = $context->get_context_name(); if ($request->searcharea) { $contextname .= ' (search area: ' . $request->searcharea . ')'; } $progress->output('Indexing requested context: ' . $contextname); // Actually index the context. $result = $this->index_context($context, $request->searcharea, $remainingtime, $progress, $request->partialarea, $request->partialtime); // Work out shared part of message. $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)'; // Update database table and continue/stop as appropriate. if ($result->complete) { // If we completed the request, remove it from the table. $DB->delete_records('search_index_requests', ['id' => $request->id]); $progress->output('Completed requested context: ' . $endmessage); } else { // If we didn't complete the request, store the partial details (how far it got). $DB->update_record('search_index_requests', ['id' => $request->id, 'partialarea' => $result->startfromarea, 'partialtime' => $result->startfromtime]); $progress->output('Ending requested context: ' . $endmessage); // The time limit must have expired, so stop looping. break; } } } /** * Gets information about the request queue, in the form of a plain object suitable for passing * to a template for rendering. * * @return \stdClass Information about queued index requests */ public function get_index_requests_info() { global $DB; $result = new \stdClass(); $result->total = $DB->count_records('search_index_requests'); $result->topten = $DB->get_records('search_index_requests', null, 'indexpriority DESC, timerequested, contextid, searcharea', 'id, contextid, timerequested, searcharea, partialarea, partialtime, indexpriority', 0, 10); foreach ($result->topten as $item) { $context = \context::instance_by_id($item->contextid); $item->contextlink = \html_writer::link($context->get_url(), s($context->get_context_name())); if ($item->searcharea) { $item->areaname = $this->get_search_area($item->searcharea)->get_visible_name(); } if ($item->partialarea) { $item->partialareaname = $this->get_search_area($item->partialarea)->get_visible_name(); } switch ($item->indexpriority) { case self::INDEX_PRIORITY_REINDEXING : $item->priorityname = get_string('priority_reindexing', 'search'); break; case self::INDEX_PRIORITY_NORMAL : $item->priorityname = get_string('priority_normal', 'search'); break; } } // Normalise array indices. $result->topten = array_values($result->topten); if ($result->total > 10) { $result->ellipsis = true; } return $result; } /** * Gets current time for use in search system. * * Note: This should be replaced with generic core functionality once possible (see MDL-60644). * * @return float Current time in seconds (with decimals) */ public static function get_current_time() { if (PHPUNIT_TEST && self::$phpunitfaketime) { return self::$phpunitfaketime; } return microtime(true); } /** * Check if search area categories functionality is enabled. * * @return bool */ public static function is_search_area_categories_enabled() { return !empty(get_config('core', 'searchenablecategories')); } /** * Check if all results category should be hidden. * * @return bool */ public static function should_hide_all_results_category() { return get_config('core', 'searchhideallcategory'); } /** * Returns default search area category name. * * @return string */ public static function get_default_area_category_name() { $default = get_config('core', 'searchdefaultcategory'); if (empty($default)) { $default = self::SEARCH_AREA_CATEGORY_ALL; } if ($default == self::SEARCH_AREA_CATEGORY_ALL && self::should_hide_all_results_category()) { $default = self::SEARCH_AREA_CATEGORY_COURSE_CONTENT; } return $default; } /** * Get a list of all courses limited by ids if required. * * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. * @return array */ protected function get_all_courses($limitcourseids) { global $DB; if ($limitcourseids) { list ($coursesql, $courseparams) = $DB->get_in_or_equal($limitcourseids); $coursesql = 'id ' . $coursesql; } else { $coursesql = ''; $courseparams = []; } // Get courses using the same list of fields from enrol_get_my_courses. return $DB->get_records_select('course', $coursesql, $courseparams, '', 'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' . 'groupmode, groupmodeforce, cacherev'); } /** * Get a list of courses as user can access. * * @param bool $allaccessible Include courses user is not enrolled in, but can access. * @return array */ protected function get_my_courses($allaccessible) { return enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [], $allaccessible); } /** * Check if search all courses setting is enabled. * * @return bool */ public static function include_all_courses() { return !empty(get_config('core', 'searchincludeallcourses')); } /** * Cleans up non existing search area. * * 1. Remove all configs from {config_plugins} table. * 2. Delete all related indexed documents. * * @param string $areaid Search area id. */ public static function clean_up_non_existing_area($areaid) { global $DB; if (!empty(self::get_search_area($areaid))) { throw new \coding_exception("Area $areaid exists. Please use appropriate search area class to manipulate the data."); } $parts = self::parse_areaid($areaid); $plugin = $parts[0]; $configprefix = $parts[1]; foreach (base::get_settingnames() as $settingname) { $name = $configprefix. $settingname; $DB->delete_records('config_plugins', ['name' => $name, 'plugin' => $plugin]); } $engine = self::instance()->get_engine(); $engine->delete($areaid); } /** * Informs the search system that a context has been deleted. * * This will clear the data from the search index, where the search engine supports that. * * This function does not usually throw an exception (so as not to get in the way of the * context deletion finishing). * * This is called for all types of context deletion. * * @param \context $context Context object that has just been deleted */ public static function context_deleted(\context $context) { if (self::is_indexing_enabled()) { try { // Hold on, are we deleting a course? If so, and this context is part of the course, // then don't bother to send a delete because we delete the whole course at once // later. if (!empty(self::$coursedeleting)) { $coursecontext = $context->get_course_context(false); if ($coursecontext && array_key_exists($coursecontext->instanceid, self::$coursedeleting)) { // Skip further processing. return; } } $engine = self::instance()->get_engine(); $engine->delete_index_for_context($context->id); } catch (\moodle_exception $e) { debugging('Error deleting search index data for context ' . $context->id . ': ' . $e->getMessage()); } } } /** * Informs the search system that a course is about to be deleted. * * This prevents it from sending hundreds of 'delete context' updates for all the individual * contexts that are deleted. * * If you call this, you must call course_deleting_finish(). * * @param int $courseid Course id that is being deleted */ public static function course_deleting_start(int $courseid) { self::$coursedeleting[$courseid] = true; } /** * Informs the search engine that a course has now been deleted. * * This causes the search engine to actually delete the index for the whole course. * * @param int $courseid Course id that no longer exists */ public static function course_deleting_finish(int $courseid) { if (!array_key_exists($courseid, self::$coursedeleting)) { // Show a debug warning. It doesn't actually matter very much, as we will now delete // the course data anyhow. debugging('course_deleting_start not called before deletion of ' . $courseid, DEBUG_DEVELOPER); } unset(self::$coursedeleting[$courseid]); if (self::is_indexing_enabled()) { try { $engine = self::instance()->get_engine(); $engine->delete_index_for_course($courseid); } catch (\moodle_exception $e) { debugging('Error deleting search index data for course ' . $courseid . ': ' . $e->getMessage()); } } } } classes/base_mod.php000064400000027531152265255700010504 0ustar00. /** * Search area base class for areas working at module level. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base implementation for search areas working at module level. * * Even if the search area works at multiple levels, if module is one of these levels * it should extend this class, as this class provides helper methods for module level search management. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_mod extends base { /** * The context levels the search area is working on. * * This can be overwriten by the search area if it works at multiple * levels. * * @var array */ protected static $levels = [CONTEXT_MODULE]; /** * Returns the module name. * * @return string */ protected function get_module_name() { return substr($this->componentname, 4); } /** * Gets the course module for the required instanceid + modulename. * * The returned data depends on the logged user, when calling this through * self::get_document the admin user is used so everything would be returned. * * No need more internal caching here, modinfo is already cached. * * @throws \dml_missing_record_exception * @param string $modulename The module name * @param int $instanceid Module instance id (depends on the module) * @param int $courseid Helps speeding up things * @return \cm_info */ protected function get_cm($modulename, $instanceid, $courseid) { $modinfo = get_fast_modinfo($courseid); // Hopefully not many, they are indexed by cmid. $instances = $modinfo->get_instances_of($modulename); foreach ($instances as $cminfo) { if ($cminfo->instance == $instanceid) { return $cminfo; } } // Nothing found. throw new \dml_missing_record_exception($modulename); } /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context. * * The SQL returned will be zero or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in a module table. * * You can pass in a null or system context, which will both return an empty string and no * params. * * Returns an array with two nulls if there can be no results for the activity within this * context (e.g. it is a block context). * * If named parameters are used, these will be named gcrs0, gcrs1, etc. The table aliases used * in SQL also all begin with gcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $modname Name of module e.g. 'forum' * @param string $modtable Alias of table containing module id * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters; both null if no need to query * @throws \coding_exception If called with invalid params */ protected function get_context_restriction_sql(?\context $context, $modname, $modtable, $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $param3 = '?'; $key1 = 0; $key2 = 1; $key3 = 2; break; case SQL_PARAMS_NAMED: $param1 = ':gcrs0'; $param2 = ':gcrs1'; $param3 = ':gcrs2'; $key1 = 'gcrs0'; $key2 = 'gcrs1'; $key3 = 'gcrs2'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: // Find all activities of this type within the specified category or any // sub-category. $pathmatch = $DB->sql_like('gcrscc2.path', $DB->sql_concat('gcrscc1.path', $param3)); $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param1) JOIN {course} gcrsc ON gcrsc.id = gcrscm.course JOIN {course_categories} gcrscc1 ON gcrscc1.id = $param2 JOIN {course_categories} gcrscc2 ON gcrscc2.id = gcrsc.category AND (gcrscc2.id = gcrscc1.id OR $pathmatch) "; $params[$key1] = $modname; $params[$key2] = $context->instanceid; // Note: This param is a bit annoying as it obviously never changes, but sql_like // throws a debug warning if you pass it anything with quotes in, so it has to be // a bound parameter. $params[$key3] = '/%'; break; case CONTEXT_COURSE: // Find all activities of this type within the course. $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.course = $param1 AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param2) "; $params[$key1] = $context->instanceid; $params[$key2] = $modname; break; case CONTEXT_MODULE: // Find only the specified activity of this type. $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.id = $param1 AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param2) "; $params[$key1] = $context->instanceid; $params[$key2] = $modname; break; case CONTEXT_BLOCK: case CONTEXT_USER: // These contexts cannot contain any activities, so return null. return [null, null]; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * This can be used in subclasses to change ordering within the get_contexts_to_reindex * function. * * It returns 2 values: * - Extra SQL joins (tables course_modules 'cm' and context 'x' already exist). * - An ORDER BY value which must use aggregate functions, by default 'MAX(cm.added) DESC'. * * Note the query already includes a GROUP BY on the context fields, so if your joins result * in multiple rows, you can use aggregate functions in the ORDER BY. See forum for an example. * * @return string[] Array with 2 elements; extra joins for the query, and ORDER BY value */ protected function get_contexts_to_reindex_extra_sql() { return ['', 'MAX(cm.added) DESC']; } /** * Gets a list of all contexts to reindex when reindexing this search area. * * For modules, the default is to return all contexts for modules of that type, in order of * time added (most recent first). * * @return \Iterator Iterator of contexts to reindex * @throws \moodle_exception If any DB error */ public function get_contexts_to_reindex() { global $DB; list ($extrajoins, $dborder) = $this->get_contexts_to_reindex_extra_sql(); $contexts = []; $selectcolumns = \context_helper::get_preload_record_columns_sql('x'); $groupbycolumns = ''; foreach (\context_helper::get_preload_record_columns('x') as $column => $thing) { if ($groupbycolumns !== '') { $groupbycolumns .= ','; } $groupbycolumns .= $column; } $rs = $DB->get_recordset_sql(" SELECT $selectcolumns FROM {course_modules} cm JOIN {context} x ON x.instanceid = cm.id AND x.contextlevel = ? $extrajoins WHERE cm.module = (SELECT id FROM {modules} WHERE name = ?) GROUP BY $groupbycolumns ORDER BY $dborder", [CONTEXT_MODULE, $this->get_module_name()]); return new \core\dml\recordset_walk($rs, function($rec) { $id = $rec->ctxid; \context_helper::preload_from_record($rec); return \context::instance_by_id($id); }); } /** * Indicates whether this search area may restrict access by group. * * This should return true if the search area (sometimes) sets the 'groupid' schema field, and * false if it never sets that field. * * (If this function returns false, but the field is set, then results may be restricted * unintentionally.) * * If this returns true, the search engine will automatically apply group restrictions in some * cases (by default, where a module is configured to use separate groups). See function * restrict_cm_access_by_group(). * * @return bool */ public function supports_group_restriction() { return false; } /** * Checks whether the content of this search area should be restricted by group for a * specific module. Called at query time. * * The default behaviour simply checks if the effective group mode is SEPARATEGROUPS, which * is probably correct for most cases. * * If restricted by group, the search query will (where supported by the engine) filter out * results for groups the user does not belong to, unless the user has 'access all groups' * for the activity. This affects only documents which set the 'groupid' field; results with no * groupid will not be restricted. * * Even if you return true to this function, you may still need to do group access checks in * check_access, because the search engine may not support group restrictions. * * @param \cm_info $cm * @return bool True to restrict by group */ public function restrict_cm_access_by_group(\cm_info $cm) { return $cm->effectivegroupmode == SEPARATEGROUPS; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc): document_icon { return new document_icon('monologo', $this->get_module_name()); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT]; } } classes/output/renderer.php000064400000013274152265255700012100 0ustar00. /** * Search renderer. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\output; defined('MOODLE_INTERNAL') || die(); /** * Search renderer. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * @var int Max number chars to display of a string value */ const SEARCH_RESULT_STRING_SIZE = 100; /** * @var int Max number chars to display of a text value */ const SEARCH_RESULT_TEXT_SIZE = 500; /** * Renders search results. * * @param \core_search\document[] $results * @param int $page Zero based page number. * @param int $totalcount Total number of results available. * @param \moodle_url $url * @param \core_search\area_category|null $cat Selected search are category or null if category functionality is disabled. * @return string HTML */ public function render_results($results, $page, $totalcount, $url, $cat = null) { $content = ''; if (\core_search\manager::is_search_area_categories_enabled() && !empty($cat)) { $toprow = []; foreach (\core_search\manager::get_search_area_categories() as $category) { $taburl = clone $url; $taburl->param('cat', $category->get_name()); $taburl->param('page', 0); $taburl->remove_params(['page', 'areaids']); $toprow[$category->get_name()] = new \tabobject($category->get_name(), $taburl, $category->get_visiblename()); } if (\core_search\manager::should_hide_all_results_category()) { unset($toprow[\core_search\manager::SEARCH_AREA_CATEGORY_ALL]); } $content .= $this->tabtree($toprow, $cat->get_name()); } // Paging bar. $perpage = \core_search\manager::DISPLAY_RESULTS_PER_PAGE; $content .= $this->output->paging_bar($totalcount, $page, $perpage, $url); // Results. $resultshtml = array(); foreach ($results as $hit) { $resultshtml[] = $this->render_result($hit); } $content .= \html_writer::tag('div', implode('
', $resultshtml), array('class' => 'search-results')); // Paging bar. $content .= $this->output->paging_bar($totalcount, $page, $perpage, $url); return $content; } /** * Top results content * * @param \core_search\document[] $results Search Results * @return string content of the top result section */ public function render_top_results($results): string { $content = $this->output->box_start('topresults'); $content .= $this->output->heading(get_string('topresults', 'core_search')); $content .= \html_writer::tag('hr', ''); $resultshtml = array(); foreach ($results as $hit) { $resultshtml[] = $this->render_result($hit); } $content .= \html_writer::tag('div', implode('
', $resultshtml), array('class' => 'search-results')); $content .= $this->output->box_end(); return $content; } /** * Displaying search results. * * @param \core_search\document Containing a single search response to be displayed.a * @return string HTML */ public function render_result(\core_search\document $doc) { $docdata = $doc->export_for_template($this); // Limit text fields size. $docdata['title'] = shorten_text($docdata['title'], static::SEARCH_RESULT_STRING_SIZE, true); $docdata['content'] = $docdata['content'] ? shorten_text($docdata['content'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; $docdata['description1'] = $docdata['description1'] ? shorten_text($docdata['description1'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; $docdata['description2'] = $docdata['description2'] ? shorten_text($docdata['description2'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; return $this->output->render_from_template('core_search/result', $docdata); } /** * Returns a box with a search disabled lang string. * * @return string HTML */ public function render_search_disabled() { $content = $this->output->box_start(); $content .= $this->output->notification(get_string('globalsearchdisabled', 'search'), 'notifymessage'); $content .= $this->output->box_end(); return $content; } /** * Returns information about queued index requests. * * @param \stdClass $info Info object from get_index_requests_info * @return string HTML * @throws \moodle_exception Any error with template */ public function render_index_requests_info(\stdClass $info) { return $this->output->render_from_template('core_search/index_requests', $info); } } classes/output/form/search.php000064400000021216152265255700012475 0ustar00. /** * Global search search form definition * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\output\form; use core_search\manager; defined('MOODLE_INTERNAL') || die; require_once($CFG->libdir . '/formslib.php'); class search extends \moodleform { /** * Form definition. * * @return void */ function definition() { global $USER, $DB, $OUTPUT; $mform =& $this->_form; if (\core_search\manager::is_search_area_categories_enabled() && !empty($this->_customdata['cat'])) { $mform->addElement('hidden', 'cat'); $mform->setType('cat', PARAM_NOTAGS); $mform->setDefault('cat', $this->_customdata['cat']); } $mform->disable_form_change_checker(); $mform->addElement('header', 'search', get_string('search', 'search')); // Help info depends on the selected search engine. $mform->addElement('text', 'q', get_string('enteryoursearchquery', 'search')); $mform->addHelpButton('q', 'searchinfo', $this->_customdata['searchengine']); $mform->setType('q', PARAM_TEXT); $mform->addRule('q', get_string('required'), 'required', null, 'client'); // Show the 'search within' option if the user came from a particular context. if (!empty($this->_customdata['searchwithin'])) { $mform->addElement('select', 'searchwithin', get_string('searchwithin', 'search'), $this->_customdata['searchwithin']); $mform->setDefault('searchwithin', ''); } // If the search engine provides multiple ways to order results, show options. if (!empty($this->_customdata['orderoptions']) && count($this->_customdata['orderoptions']) > 1) { $mform->addElement('select', 'order', get_string('order', 'search'), $this->_customdata['orderoptions']); $mform->setDefault('order', 'relevance'); } $mform->addElement('header', 'filtersection', get_string('filterheader', 'search')); $mform->setExpanded('filtersection', false); $mform->addElement('text', 'title', get_string('title', 'search')); $mform->setType('title', PARAM_TEXT); $search = \core_search\manager::instance(true); $enabledsearchareas = \core_search\manager::get_search_areas_list(true); $areanames = array(); if (\core_search\manager::is_search_area_categories_enabled() && !empty($this->_customdata['cat'])) { $searchareacategory = \core_search\manager::get_search_area_category_by_name($this->_customdata['cat']); $searchareas = $searchareacategory->get_areas(); foreach ($searchareas as $areaid => $searcharea) { if (key_exists($areaid, $enabledsearchareas)) { $areanames[$areaid] = $searcharea->get_visible_name(); } } } else { foreach ($enabledsearchareas as $areaid => $searcharea) { $areanames[$areaid] = $searcharea->get_visible_name(); } } // Sort the array by the text. \core_collator::asort($areanames); $options = array( 'multiple' => true, 'noselectionstring' => get_string('allareas', 'search'), ); $mform->addElement('autocomplete', 'areaids', get_string('searcharea', 'search'), $areanames, $options); if (is_siteadmin()) { $limittoenrolled = false; } else { $limittoenrolled = !manager::include_all_courses(); } $options = array( 'multiple' => true, 'limittoenrolled' => $limittoenrolled, 'noselectionstring' => get_string('allcourses', 'search'), ); $mform->addElement('course', 'courseids', get_string('courses', 'core'), $options); $mform->setType('courseids', PARAM_INT); if (manager::include_all_courses() || !empty(get_config('core', 'searchallavailablecourses'))) { $mform->addElement('checkbox', 'mycoursesonly', get_string('mycoursesonly', 'search')); $mform->setType('mycoursesonly', PARAM_INT); } // If the search engine can search by user, and the user is logged in (so we have // permission to call the user-listing web service) then show the user selector. if ($search->get_engine()->supports_users() && isloggedin()) { $options = [ 'ajax' => 'core_search/form-search-user-selector', 'multiple' => true, 'noselectionstring' => get_string('allusers', 'search'), 'valuehtmlcallback' => function($value) { global $DB, $OUTPUT; $user = $DB->get_record('user', ['id' => (int)$value], '*', IGNORE_MISSING); if (!$user || !user_can_view_profile($user)) { return false; } $details = user_get_user_details($user); return $OUTPUT->render_from_template( 'core_search/form-user-selector-suggestion', $details); } ]; if (!empty($this->_customdata['withincourseid'])) { $options['withincourseid'] = $this->_customdata['withincourseid']; } $mform->addElement('autocomplete', 'userids', get_string('users'), [], $options); } if (!empty($this->_customdata['searchwithin'])) { // Course options should be hidden if we choose to search within a specific location. $mform->hideIf('courseids', 'searchwithin', 'ne', ''); // Get groups on course (we don't show group selector if there aren't any). $courseid = $this->_customdata['withincourseid']; $allgroups = groups_get_all_groups($courseid); if ($allgroups && $search->get_engine()->supports_group_filtering()) { $groupnames = []; foreach ($allgroups as $group) { $groupnames[$group->id] = $group->name; } // Create group autocomplete option. $options = array( 'multiple' => true, 'noselectionstring' => get_string('allgroups'), ); $mform->addElement('autocomplete', 'groupids', get_string('groups'), $groupnames, $options); // Is the second 'search within' option a cm? if (!empty($this->_customdata['withincmid'])) { // Find out if the cm supports groups. $modinfo = get_fast_modinfo($courseid); $cm = $modinfo->get_cm($this->_customdata['withincmid']); if ($cm->effectivegroupmode != NOGROUPS) { // If it does, group ids are available when you have course or module selected. $mform->hideIf('groupids', 'searchwithin', 'eq', ''); } else { // Group ids are only available if you have course selected. $mform->hideIf('groupids', 'searchwithin', 'ne', 'course'); } } else { $mform->hideIf('groupids', 'searchwithin', 'eq', ''); } } } $mform->addElement('date_time_selector', 'timestart', get_string('fromtime', 'search'), array('optional' => true)); $mform->setDefault('timestart', 0); $mform->addElement('date_time_selector', 'timeend', get_string('totime', 'search'), array('optional' => true)); $mform->setDefault('timeend', 0); // Source context i.e. the page they came from when they clicked search. $mform->addElement('hidden', 'context'); $mform->setType('context', PARAM_INT); $this->add_action_buttons(false, get_string('search', 'search')); } } classes/base_block.php000064400000042346152265255700011020 0ustar00. /** * Search area base class for blocks. * * Note: Only blocks within courses are supported. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search area base class for blocks. * * Note: Only blocks within courses are supported. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_block extends base { /** @var string Cache name used for block instances */ const CACHE_INSTANCES = 'base_block_instances'; /** * The context levels the search area is working on. * * This can be overwriten by the search area if it works at multiple * levels. * * @var array */ protected static $levels = [CONTEXT_BLOCK]; /** * Gets the block name only. * * @return string Block name e.g. 'html' */ public function get_block_name() { // Remove 'block_' text. return substr($this->get_component_name(), 6); } /** * Returns restrictions on which block_instances rows to return. By default, excludes rows * that have empty configdata. * * If no restriction is required, you could return ['', []]. * * @return array 2-element array of SQL restriction and params for it */ protected function get_indexing_restrictions() { global $DB; // This includes completely empty configdata, and also three other values that are // equivalent to empty: // - A serialized completely empty object. // - A serialized object with one field called '0' (string not int) set to boolean false // (this can happen after backup and restore, at least historically). // - A serialized null. $stupidobject = (object)[]; $zero = '0'; $stupidobject->{$zero} = false; return [$DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ?", ['', base64_encode(serialize((object)[])), base64_encode(serialize($stupidobject)), base64_encode(serialize(null))]]; } /** * Gets recordset of all blocks of this type modified since given time within the given context. * * See base class for detailed requirements. This implementation includes the key fields * from block_instances. * * This can be overridden to do something totally different if the block's data is stored in * other tables. * * If there are certain instances of the block which should not be included in the search index * then you can override get_indexing_restrictions; by default this excludes rows with empty * configdata. * * @param int $modifiedfrom Return only records modified after this date * @param \context|null $context Context to find blocks within * @return false|\moodle_recordset|null */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; // Get context restrictions. list ($contextjoin, $contextparams) = $this->get_context_restriction_sql($context, 'bi'); // Get custom restrictions for block type. list ($restrictions, $restrictionparams) = $this->get_indexing_restrictions(); if ($restrictions) { $restrictions = 'AND ' . $restrictions; } // Query for all entries in block_instances for this type of block, within the specified // context. The query is based on the one from get_recordset_by_timestamp and applies the // same restrictions. return $DB->get_recordset_sql(" SELECT bi.id, bi.timemodified, bi.timecreated, bi.configdata, c.id AS courseid, x.id AS contextid FROM {block_instances} bi $contextjoin JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? JOIN {context} parent ON parent.id = bi.parentcontextid LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ? JOIN {course} c ON c.id = cm.course OR (c.id = parent.instanceid AND parent.contextlevel = ?) WHERE bi.timemodified >= ? AND bi.blockname = ? AND (parent.contextlevel = ? AND (" . $DB->sql_like('bi.pagetypepattern', '?') . " OR bi.pagetypepattern IN ('site-index', 'course-*', '*'))) $restrictions ORDER BY bi.timemodified ASC", array_merge($contextparams, [CONTEXT_BLOCK, CONTEXT_MODULE, CONTEXT_COURSE, $modifiedfrom, $this->get_block_name(), CONTEXT_COURSE, 'course-view-%'], $restrictionparams)); } public function get_doc_url(\core_search\document $doc) { // Load block instance and find cmid if there is one. $blockinstanceid = preg_replace('~^.*-~', '', $doc->get('id')); $instance = $this->get_block_instance($blockinstanceid); $courseid = $doc->get('courseid'); $anchor = 'inst' . $blockinstanceid; // Check if the block is at course or module level. if ($instance->cmid) { // No module-level page types are supported at present so the search system won't return // them. But let's put some example code here to indicate how it could work. debugging('Unexpected module-level page type for block ' . $blockinstanceid . ': ' . $instance->pagetypepattern, DEBUG_DEVELOPER); $modinfo = get_fast_modinfo($courseid); $cm = $modinfo->get_cm($instance->cmid); return new \moodle_url($cm->url, null, $anchor); } else { // The block is at course level. Let's check the page type, although in practice we // currently only support the course main page. if ($instance->pagetypepattern === '*' || $instance->pagetypepattern === 'course-*' || preg_match('~^course-view-(.*)$~', $instance->pagetypepattern)) { return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor); } else if ($instance->pagetypepattern === 'site-index') { return new \moodle_url('/', ['redirect' => 0], $anchor); } else { debugging('Unexpected page type for block ' . $blockinstanceid . ': ' . $instance->pagetypepattern, DEBUG_DEVELOPER); return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor); } } } public function get_context_url(\core_search\document $doc) { return $this->get_doc_url($doc); } /** * Checks access for a document in this search area. * * If you override this function for a block, you should call this base class version first * as it will check that the block is still visible to users in a supported location. * * @param int $id Document id * @return int manager:ACCESS_xx constant */ public function check_access($id) { $instance = $this->get_block_instance($id, IGNORE_MISSING); if (!$instance) { // This generally won't happen because if the block has been deleted then we won't have // included its context in the search area list, but just in case. return manager::ACCESS_DELETED; } // Check block has not been moved to an unsupported area since it was indexed. (At the // moment, only blocks within site and course context are supported, also only certain // page types.) if (!$instance->courseid || !self::is_supported_page_type_at_course_context($instance->pagetypepattern)) { return manager::ACCESS_DELETED; } // Note we do not need to check if the block was hidden or if the user has access to the // context, because those checks are included in the list of search contexts user can access // that is calculated in manager.php every time they do a query. return manager::ACCESS_GRANTED; } /** * Checks if a page type is supported for blocks when at course (or also site) context. This * function should be consistent with the SQL in get_recordset_by_timestamp. * * @param string $pagetype Page type * @return bool True if supported */ protected static function is_supported_page_type_at_course_context($pagetype) { if (in_array($pagetype, ['site-index', 'course-*', '*'])) { return true; } if (preg_match('~^course-view-~', $pagetype)) { return true; } return false; } /** * Gets a block instance with given id. * * Returns the fields id, pagetypepattern, subpagepattern from block_instances and also the * cmid (if parent context is an activity module). * * @param int $id ID of block instance * @param int $strictness MUST_EXIST or IGNORE_MISSING * @return false|mixed Block instance data (may be false if strictness is IGNORE_MISSING) */ protected function get_block_instance($id, $strictness = MUST_EXIST) { global $DB; $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search', self::CACHE_INSTANCES, [], ['simplekeys' => true]); $id = (int)$id; $instance = $cache->get($id); if (!$instance) { $instance = $DB->get_record_sql(" SELECT bi.id, bi.pagetypepattern, bi.subpagepattern, c.id AS courseid, cm.id AS cmid FROM {block_instances} bi JOIN {context} parent ON parent.id = bi.parentcontextid LEFT JOIN {course} c ON c.id = parent.instanceid AND parent.contextlevel = ? LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ? WHERE bi.id = ?", [CONTEXT_COURSE, CONTEXT_MODULE, $id], $strictness); $cache->set($id, $instance); } return $instance; } /** * Clears static cache. This function can be removed (with calls to it in the test script * replaced with cache_helper::purge_all) if MDL-59427 is fixed. */ public static function clear_static() { \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search', self::CACHE_INSTANCES, [], ['simplekeys' => true])->purge(); } /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context. * * The SQL returned will be one or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in the block_instances table. * * We assume the block instances have already been restricted by blockname. * * Returns null if there can be no results for this block within this context. * * If named parameters are used, these will be named gcrs0, gcrs1, etc. The table aliases used * in SQL also all begin with gcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $blocktable Alias of block_instances table * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters * @throws \coding_exception If called with invalid params */ protected function get_context_restriction_sql(?\context $context = null, $blocktable = 'bi', $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $key1 = 0; $key2 = 1; break; case SQL_PARAMS_NAMED: $param1 = ':gcrs0'; $param2 = ':gcrs1'; $key1 = 'gcrs0'; $key2 = 'gcrs1'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: case CONTEXT_COURSE: case CONTEXT_MODULE: case CONTEXT_USER: // Find all blocks whose parent is within the specified context. $sql = " JOIN {context} gcrsx ON gcrsx.id = $blocktable.parentcontextid AND (gcrsx.id = $param1 OR " . $DB->sql_like('gcrsx.path', $param2) . ") "; $params[$key1] = $context->id; $params[$key2] = $context->path . '/%'; break; case CONTEXT_BLOCK: // Find only the specified block of this type. Since we are generating JOINs // here, we do this by joining again to the block_instances table with the same ID. $sql = " JOIN {block_instances} gcrsbi ON gcrsbi.id = $blocktable.id AND gcrsbi.id = $param1 "; $params[$key1] = $context->instanceid; break; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * This can be used in subclasses to change ordering within the get_contexts_to_reindex * function. * * It returns 2 values: * - Extra SQL joins (tables block_instances 'bi' and context 'x' already exist). * - An ORDER BY value which must use aggregate functions, by default 'MAX(bi.timemodified) DESC'. * * Note the query already includes a GROUP BY on the context fields, so if your joins result * in multiple rows, you can use aggregate functions in the ORDER BY. See forum for an example. * * @return string[] Array with 2 elements; extra joins for the query, and ORDER BY value */ protected function get_contexts_to_reindex_extra_sql() { return ['', 'MAX(bi.timemodified) DESC']; } /** * Gets a list of all contexts to reindex when reindexing this search area. * * For blocks, the default is to return all contexts for blocks of that type, that are on a * course page, in order of time added (most recent first). * * @return \Iterator Iterator of contexts to reindex * @throws \moodle_exception If any DB error */ public function get_contexts_to_reindex() { global $DB; list ($extrajoins, $dborder) = $this->get_contexts_to_reindex_extra_sql(); $contexts = []; $selectcolumns = \context_helper::get_preload_record_columns_sql('x'); $groupbycolumns = ''; foreach (\context_helper::get_preload_record_columns('x') as $column => $thing) { if ($groupbycolumns !== '') { $groupbycolumns .= ','; } $groupbycolumns .= $column; } $rs = $DB->get_recordset_sql(" SELECT $selectcolumns FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? JOIN {context} parent ON parent.id = bi.parentcontextid $extrajoins WHERE bi.blockname = ? AND parent.contextlevel = ? GROUP BY $groupbycolumns ORDER BY $dborder", [CONTEXT_BLOCK, $this->get_block_name(), CONTEXT_COURSE]); return new \core\dml\recordset_walk($rs, function($rec) { $id = $rec->ctxid; \context_helper::preload_from_record($rec); return \context::instance_by_id($id); }); } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc): document_icon { return new document_icon('e/anchor'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT]; } } classes/base_activity.php000064400000021171152265255700011553 0ustar00. /** * Search area base class for activities. * * @package core_search * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base implementation for activity modules. * * @package core_search * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_activity extends base_mod { /** * @var string The time modified field name. * * Activities not using timemodified as field name * can overwrite this constant. */ const MODIFIED_FIELD_NAME = 'timemodified'; /** * Activities with a time created field can overwrite this constant. */ const CREATED_FIELD_NAME = ''; /** * The context levels the search area is working on. * @var array */ protected static $levels = [CONTEXT_MODULE]; /** @var array activity data instance. */ public $activitiesdata = []; /** * Returns recordset containing all activities within the given context. * * @param \context|null $context Context * @param int $modifiedfrom Return only records modified after this date * @return \moodle_recordset|null Recordset, or null if no possible activities in given context */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; list ($contextjoin, $contextparams) = $this->get_context_restriction_sql( $context, $this->get_module_name(), 'modtable'); if ($contextjoin === null) { return null; } return $DB->get_recordset_sql('SELECT modtable.* FROM {' . $this->get_module_name() . '} modtable ' . $contextjoin . ' WHERE modtable.' . static::MODIFIED_FIELD_NAME . ' >= ? ORDER BY modtable.' . static::MODIFIED_FIELD_NAME . ' ASC', array_merge($contextparams, [$modifiedfrom])); } /** * Returns the document associated with this activity. * * This default implementation for activities sets the activity name to title and the activity intro to * content. Any activity can overwrite this function if it is interested in setting other fields than the * default ones, or to fill description optional fields with extra stuff. * * @param \stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { try { $cm = $this->get_cm($this->get_module_name(), $record->id, $record->course); $context = \context_module::instance($cm->id); } catch (\dml_missing_record_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } catch (\dml_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', content_to_text($record->name, false)); $doc->set('content', content_to_text($record->intro, $record->introformat)); $doc->set('contextid', $context->id); $doc->set('courseid', $record->course); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('modified', $record->{static::MODIFIED_FIELD_NAME}); // Check if this document should be considered new. if (isset($options['lastindexedtime'])) { $createdfield = static::CREATED_FIELD_NAME; if (!empty($createdfield) && ($options['lastindexedtime'] < $record->{$createdfield})) { // If the document was created after the last index time, it must be new. $doc->set_is_new(true); } } return $doc; } /** * Whether the user can access the document or not. * * @throws \dml_missing_record_exception * @throws \dml_exception * @param int $id The activity instance id. * @return bool */ public function check_access($id) { global $DB; try { $activity = $this->get_activity($id); $cminfo = $this->get_cm($this->get_module_name(), $activity->id, $activity->course); $cminfo->get_course_module_record(); } catch (\dml_missing_record_exception $ex) { return \core_search\manager::ACCESS_DELETED; } catch (\dml_exception $ex) { return \core_search\manager::ACCESS_DENIED; } // Recheck uservisible although it should have already been checked in core_search. if ($cminfo->uservisible === false) { return \core_search\manager::ACCESS_DENIED; } return \core_search\manager::ACCESS_GRANTED; } /** * Link to the module instance. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } /** * Link to the module instance. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { $cminfo = $this->get_cm($this->get_module_name(), strval($doc->get('itemid')), $doc->get('courseid')); return new \moodle_url('/mod/' . $this->get_module_name() . '/view.php', array('id' => $cminfo->id)); } /** * Returns an activity instance. Internally uses the class component to know which activity module should be retrieved. * * @param int $instanceid * @return stdClass */ protected function get_activity($instanceid) { global $DB; if (empty($this->activitiesdata[$this->get_module_name()][$instanceid])) { $this->activitiesdata[$this->get_module_name()][$instanceid] = $DB->get_record($this->get_module_name(), array('id' => $instanceid), '*', MUST_EXIST); } return $this->activitiesdata[$this->get_module_name()][$instanceid]; } /** * Return the context info required to index files for * this search area. * * Should be onerridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array( 'intro' // Fileareas. ); return $fileareas; } /** * Files related to the current document are attached, * to the document object ready for indexing by * Global Search. * * The default implementation retrieves all files for * the file areas returned by get_search_fileareas(). * If you need to filter files to specific items per * file area, you will need to override this method * and explicitly provide the items. * * @param document $document The current document * @return void */ public function attach_files($document) { $fileareas = $this->get_search_fileareas(); if (!empty($fileareas)) { $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid')); $context = \context_module::instance($cm->id); $contextid = $context->id; $fs = get_file_storage(); $files = $fs->get_area_files($contextid, $this->get_component_name(), $fileareas, false, '', false); foreach ($files as $file) { $document->add_stored_file($file); } } return; } } classes/engine_exception.php000064400000002317152265255700012251 0ustar00. /** * Search engine exceptions. * * @package core_search * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die; /** * Dummy class to identify search engine exceptions. * * @package core_search * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine_exception extends \moodle_exception { } classes/skip_future_documents_iterator.php000064400000006475152265255700015271 0ustar00. /** * Iterator for skipping search recordset documents that are in the future. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Iterator for skipping search recordset documents that are in the future. * * This iterator stops iterating if it receives a document that was modified later than the * specified cut-off time (usually current time). * * This iterator assumes that its parent iterator returns documents in modified order (which is * required to be the case for search indexing). This means we will stop retrieving data from the * recordset * * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class skip_future_documents_iterator implements \Iterator { /** @var \Iterator Parent iterator */ protected $parent; /** @var int Cutoff time; anything later than this will cause the iterator to stop */ protected $cutoff; /** @var mixed Current value of iterator */ protected $currentdoc; /** @var bool True if current value is available */ protected $gotcurrent; /** * Constructor. * * @param \Iterator $parent Parent iterator, must return search documents in modified order * @param int $cutoff Cut-off time, default is current time */ public function __construct(\Iterator $parent, $cutoff = 0) { $this->parent = $parent; if ($cutoff) { $this->cutoff = $cutoff; } else { $this->cutoff = time(); } } #[\ReturnTypeWillChange] public function current() { if (!$this->gotcurrent) { $this->currentdoc = $this->parent->current(); $this->gotcurrent = true; } return $this->currentdoc; } public function next(): void { $this->parent->next(); $this->gotcurrent = false; } #[\ReturnTypeWillChange] public function key() { return $this->parent->key(); } public function valid(): bool { // Check that the parent is valid. if (!$this->parent->valid()) { return false; } if ($doc = $this->current()) { // This document is valid if the modification date is before the cutoff. return $doc->get('modified') <= $this->cutoff; } else { // If the document is false/null, allow iterator to continue. return true; } } public function rewind(): void { $this->parent->rewind(); $this->gotcurrent = false; } } classes/document.php000064400000056512152265255700010552 0ustar00. /** * Document representation. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; use context; defined('MOODLE_INTERNAL') || die(); /** * Represents a document to index. * * Note that, if you are writting a search engine and you want to change \core_search\document * behaviour, you can overwrite this class, will be automatically loaded from \search_YOURENGINE\document. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document implements \renderable, \templatable { /** * @var array $data The document data. */ protected $data = array(); /** * @var array Extra data needed to render the document. */ protected $extradata = array(); /** * @var \moodle_url Link to the document. */ protected $docurl = null; /** * @var \moodle_url Link to the document context. */ protected $contexturl = null; /** * @var \core_search\document_icon Document icon instance. */ protected $docicon = null; /** * @var int|null The content field filearea. */ protected $contentfilearea = null; /** * @var int|null The content field itemid. */ protected $contentitemid = null; /** * @var bool Should be set to true if document hasn't been indexed before. False if unknown. */ protected $isnew = false; /** * @var \stored_file[] An array of stored files to attach to the document. */ protected $files = array(); /** * Change list (for engine implementers): * 2017091700 - add optional field groupid * * @var int Schema version number (update if any change) */ const SCHEMA_VERSION = 2017091700; /** * All required fields any doc should contain. * * We have to choose a format to specify field types, using solr format as we have to choose one and solr is the * default search engine. * * Search engine plugins are responsible of setting their appropriate field types and map these naming to whatever format * they need. * * @var array */ protected static $requiredfields = array( 'id' => array( 'type' => 'string', 'stored' => true, 'indexed' => false ), 'itemid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'title' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'content' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'contextid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'areaid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'type' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'courseid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'owneruserid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'modified' => array( 'type' => 'tdate', 'stored' => true, 'indexed' => true ), ); /** * All optional fields docs can contain. * * Although it matches solr fields format, this is just to define the field types. Search * engine plugins are responsible of setting their appropriate field types and map these * naming to whatever format they need. * * @var array */ protected static $optionalfields = array( 'userid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'groupid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'description1' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'description2' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ) ); /** * Any fields that are engine specifc. These are fields that are solely used by a search engine plugin * for internal purposes. * * Field names should be prefixed with engine name to avoid potential conflict with core fields. * * Uses same format as fields above. * * @var array */ protected static $enginefields = array(); /** * We ensure that the document has a unique id across search areas. * * @param int $itemid An id unique to the search area * @param string $componentname The search area component Frankenstyle name * @param string $areaname The area name (the search area class name) * @return void */ public function __construct($itemid, $componentname, $areaname) { if (!is_numeric($itemid)) { throw new \coding_exception('The itemid should be an integer'); } $this->data['areaid'] = \core_search\manager::generate_areaid($componentname, $areaname); $this->data['id'] = $this->data['areaid'] . '-' . $itemid; $this->data['itemid'] = intval($itemid); } /** * Add a stored file to the document. * * @param \stored_file|int $file The file to add, or file id. * @return void */ public function add_stored_file($file) { if (is_numeric($file)) { $this->files[$file] = $file; } else { $this->files[$file->get_id()] = $file; } } /** * Returns the array of attached files. * * @return \stored_file[] */ public function get_files() { // The files array can contain stored file ids, so we need to get instances if asked. foreach ($this->files as $id => $listfile) { if (is_numeric($listfile)) { $fs = get_file_storage(); if ($file = $fs->get_file_by_id($id)) { $this->files[$id] = $file; } else { unset($this->files[$id]); // Index is out of date and referencing a file that does not exist. } } } return $this->files; } /** * Setter. * * Basic checkings to prevent common issues. * * If the field is a string tags will be stripped, if it is an integer or a date it * will be casted to a PHP integer. tdate fields values are expected to be timestamps. * * @throws \coding_exception * @param string $fieldname The field name * @param string|int $value The value to store * @return string|int The stored value */ public function set($fieldname, $value) { if (!empty(static::$requiredfields[$fieldname])) { $fielddata = static::$requiredfields[$fieldname]; } else if (!empty(static::$optionalfields[$fieldname])) { $fielddata = static::$optionalfields[$fieldname]; } else if (!empty(static::$enginefields[$fieldname])) { $fielddata = static::$enginefields[$fieldname]; } if (empty($fielddata)) { throw new \coding_exception('"' . $fieldname . '" field does not exist.'); } // tdate fields should be set as timestamps, later they might be converted to // a date format, it depends on the search engine. if (($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') && !is_numeric($value)) { throw new \coding_exception('"' . $fieldname . '" value should be an integer and its value is "' . $value . '"'); } // We want to be strict here, there might be engines that expect us to // provide them data with the proper type already set. if ($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') { $this->data[$fieldname] = intval($value); } else { // Remove disallowed Unicode characters. $value = \core_text::remove_unicode_non_characters($value); // Replace all groups of line breaks and spaces by single spaces. $this->data[$fieldname] = preg_replace("/\s+/u", " ", $value); if ($this->data[$fieldname] === null) { if (isset($this->data['id'])) { $docid = $this->data['id']; } else { $docid = '(unknown)'; } throw new \moodle_exception('error_indexing', 'search', '', null, '"' . $fieldname . '" value causes preg_replace error (may be caused by unusual characters) ' . 'in document with id "' . $docid . '"'); } } return $this->data[$fieldname]; } /** * Sets data to this->extradata * * This data can be retrieved using \core_search\document->get($fieldname). * * @param string $fieldname * @param string $value * @return void */ public function set_extra($fieldname, $value) { $this->extradata[$fieldname] = $value; } /** * Getter. * * Use self::is_set if you are not sure if this field is set or not * as otherwise it will trigger a \coding_exception * * @throws \coding_exception * @param string $field * @return string|int */ public function get($field) { if (isset($this->data[$field])) { return $this->data[$field]; } // Fallback to extra data. if (isset($this->extradata[$field])) { return $this->extradata[$field]; } throw new \coding_exception('Field "' . $field . '" is not set in the document'); } /** * Checks if a field is set. * * @param string $field * @return bool */ public function is_set($field) { return (isset($this->data[$field]) || isset($this->extradata[$field])); } /** * Set if this is a new document. False if unknown. * * @param bool $new */ public function set_is_new($new) { $this->isnew = (bool)$new; } /** * Returns if the document is new. False if unknown. * * @return bool */ public function get_is_new() { return $this->isnew; } /** * Returns all default fields definitions. * * @return array */ public static function get_default_fields_definition() { return static::$requiredfields + static::$optionalfields + static::$enginefields; } /** * Formats the timestamp preparing the time fields to be inserted into the search engine. * * By default it just returns a timestamp so any search engine could just store integers * and use integers comparison to get documents between x and y timestamps, but search * engines might be interested in using their own field formats. They can do it extending * this class in \search_xxx\document. * * @param int $timestamp * @return string */ public static function format_time_for_engine($timestamp) { return $timestamp; } /** * Formats a string value for the search engine. * * Search engines may overwrite this method to apply restrictions, like limiting the size. * The default behaviour is just returning the string. * * @param string $string * @return string */ public static function format_string_for_engine($string) { return $string; } /** * Formats a text value for the search engine. * * Search engines may overwrite this method to apply restrictions, like limiting the size. * The default behaviour is just returning the string. * * @param string $text * @return string */ public static function format_text_for_engine($text) { return $text; } /** * Returns a timestamp from the value stored in the search engine. * * By default it just returns a timestamp so any search engine could just store integers * and use integers comparison to get documents between x and y timestamps, but search * engines might be interested in using their own field formats. They should do it extending * this class in \search_xxx\document. * * @param string $time * @return int */ public static function import_time_from_engine($time) { return $time; } /** * Returns how text is returned from the search engine. * * @return int */ protected function get_text_format() { return FORMAT_PLAIN; } /** * Fills the document with data coming from the search engine. * * @throws \core_search\engine_exception * @param array $docdata * @return void */ public function set_data_from_engine($docdata) { $fields = static::$requiredfields + static::$optionalfields + static::$enginefields; foreach ($fields as $fieldname => $field) { // Optional params might not be there. if (isset($docdata[$fieldname])) { if ($field['type'] === 'tdate') { // Time fields may need a preprocessing. $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname])); } else { // No way we can make this work if there is any multivalue field. if (is_array($docdata[$fieldname])) { throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname); } $this->set($fieldname, $docdata[$fieldname]); } } } } /** * Sets the document url. * * @param \moodle_url $url * @return void */ public function set_doc_url(\moodle_url $url) { $this->docurl = $url; } /** * Gets the url to the doc. * * @return \moodle_url */ public function get_doc_url() { return $this->docurl; } /** * Sets document icon instance. * * @param \core_search\document_icon $docicon */ public function set_doc_icon(document_icon $docicon) { $this->docicon = $docicon; } /** * Gets document icon instance. * * @return \core_search\document_icon */ public function get_doc_icon() { return $this->docicon; } public function set_context_url(\moodle_url $url) { $this->contexturl = $url; } /** * Gets the url to the context. * * @return \moodle_url */ public function get_context_url() { return $this->contexturl; } /** * Returns the document ready to submit to the search engine. * * @throws \coding_exception * @return array */ public function export_for_engine() { // Set any unset defaults. $this->apply_defaults(); // We don't want to affect the document instance. $data = $this->data; // Apply specific engine-dependant formats and restrictions. foreach (static::$requiredfields as $fieldname => $field) { // We also check that we have everything we need. if (!isset($data[$fieldname])) { throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"'); } if ($field['type'] === 'tdate') { // Overwrite the timestamp with the engine dependant format. $data[$fieldname] = static::format_time_for_engine($data[$fieldname]); } else if ($field['type'] === 'string') { // Overwrite the string with the engine dependant format. $data[$fieldname] = static::format_string_for_engine($data[$fieldname]); } else if ($field['type'] === 'text') { // Overwrite the text with the engine dependant format. $data[$fieldname] = static::format_text_for_engine($data[$fieldname]); } } $fields = static::$optionalfields + static::$enginefields; foreach ($fields as $fieldname => $field) { if (!isset($data[$fieldname])) { continue; } if ($field['type'] === 'tdate') { // Overwrite the timestamp with the engine dependant format. $data[$fieldname] = static::format_time_for_engine($data[$fieldname]); } else if ($field['type'] === 'string') { // Overwrite the string with the engine dependant format. $data[$fieldname] = static::format_string_for_engine($data[$fieldname]); } else if ($field['type'] === 'text') { // Overwrite the text with the engine dependant format. $data[$fieldname] = static::format_text_for_engine($data[$fieldname]); } } return $data; } /** * Apply any defaults to unset fields before export. Called after document building, but before export. * * Sub-classes of this should make sure to call parent::apply_defaults(). */ protected function apply_defaults() { // Set the default type, TYPE_TEXT. if (!isset($this->data['type'])) { $this->data['type'] = manager::TYPE_TEXT; } } /** * Export the document data to be used as a template context. * * Just delegates all the processing to export_doc_info, also used by external functions. * Adding more info than the required one as people might be interested in extending the template. * * @param \renderer_base $output The renderer. * @return array */ public function export_for_template(\renderer_base $output): array { $docdata = $this->export_doc($output); return $docdata; } /** * Returns the current docuement information. * * Adding more info than the required one as themers and ws clients might be interested in showing more stuff. * * Although content is a required field when setting up the document, it accepts '' (empty) values * as they may be the result of striping out HTML. * * SECURITY NOTE: It is the responsibility of the document to properly escape any text to be displayed. * The renderer will output the content without any further cleaning. * * @param \renderer_base $output The renderer. * @return array */ public function export_doc(\renderer_base $output): array { global $USER, $CFG; require_once($CFG->dirroot . '/course/lib.php'); list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid')); $context = context::instance_by_id($this->get('contextid')); $searcharea = \core_search\manager::get_search_area($this->data['areaid']); $title = $this->is_set('title') ? $this->format_text($searcharea->get_document_display_title($this)) : ''; $data = [ 'itemid' => $this->get('itemid'), 'componentname' => $componentname, 'areaname' => $areaname, 'courseurl' => (course_get_url($this->get('courseid')))->out(false), 'coursefullname' => format_string($this->get('coursefullname'), true, ['context' => $context->id]), 'modified' => userdate($this->get('modified')), 'timemodified' => $this->get('modified'), 'title' => ($title !== '') ? $title : get_string('notitle', 'search'), 'docurl' => ($this->get_doc_url())->out(false), 'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null, 'contextid' => $this->get('contextid'), 'contexturl' => ($this->get_context_url())->out(false), 'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null, 'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null, ]; // Now take any attached any files. $files = $this->get_files(); if (!empty($files)) { if (count($files) > 1) { $filenames = []; foreach ($files as $file) { $filenames[] = format_string($file->get_filename(), true, ['context' => $context->id]); } $data['multiplefiles'] = true; $data['filenames'] = $filenames; } else { $file = reset($files); $data['filename'] = format_string($file->get_filename(), true, ['context' => $context->id]); } } if ($this->is_set('userid')) { if ($this->get('userid') == $USER->id || (has_capability('moodle/user:viewdetails', $context) && has_capability('moodle/course:viewparticipants', $context))) { $data['userurl'] = (new \moodle_url( '/user/view.php', ['id' => $this->get('userid'), 'course' => $this->get('courseid')] ))->out(false); $data['userfullname'] = format_string($this->get('userfullname'), true, ['context' => $context->id]); $data['userid'] = $this->get('userid'); } } if ($docicon = $this->get_doc_icon()) { $data['icon'] = $output->image_url($docicon->get_name(), $docicon->get_component()); $data['iconurl'] = $data['icon']->out(false); $data['iconname'] = $docicon->get_name(); $data['iconcomponent'] = $docicon->get_component(); } $data['textformat'] = $this->get_text_format(); return $data; } /** * Formats a text string coming from the search engine. * * By default just return the text as it is: * - Search areas are responsible of sending just plain data, the search engine may * append HTML or markdown to it (highlighing for example). * - The view is responsible of shortening the text if it is too big * * @param string $text Text to format * @return string HTML text to be renderer */ protected function format_text($text) { return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid'))); } } classes/base.php000064400000047406152265255700007650 0ustar00. /** * Search base class to be extended by search areas. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base search implementation. * * Components and plugins interested in filling the search engine with data should extend this class (or any extension of this * class). * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base { /** * The area name as defined in the class name. * * @var string */ protected $areaname = null; /** * The component frankenstyle name. * * @var string */ protected $componentname = null; /** * The component type (core or the plugin type). * * @var string */ protected $componenttype = null; /** * The context levels the search implementation is working on. * * @var array */ protected static $levels = [CONTEXT_SYSTEM]; /** * An area id from the componentname and the area name. * * @var string */ public $areaid; /** * Constructor. * * @throws \coding_exception * @return void */ final public function __construct() { $classname = get_class($this); // Detect possible issues when defining the class. if (strpos($classname, '\search') === false) { throw new \coding_exception('Search area classes should be located in \PLUGINTYPE_PLUGINNAME\search\AREANAME.'); } else if (strpos($classname, '_') === false) { throw new \coding_exception($classname . ' class namespace level 1 should be its component frankenstyle name'); } $this->areaname = substr(strrchr($classname, '\\'), 1); $this->componentname = substr($classname, 0, strpos($classname, '\\')); $this->areaid = \core_search\manager::generate_areaid($this->componentname, $this->areaname); $this->componenttype = substr($this->componentname, 0, strpos($this->componentname, '_')); } /** * Returns context levels property. * * @return int */ public static function get_levels() { return static::$levels; } /** * Returns the area id. * * @return string */ public function get_area_id() { return $this->areaid; } /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return $this->componentname; } /** * Returns the component type. * * It might be a plugintype or 'core' for core subsystems. * * @return string */ public function get_component_type() { return $this->componenttype; } /** * Returns the area visible name. * * @param bool $lazyload Usually false, unless when in admin settings. * @return string */ public function get_visible_name($lazyload = false) { $component = $this->componentname; // Core subsystem strings go to lang/XX/search.php. if ($this->componenttype === 'core') { $component = 'search'; } return get_string('search:' . $this->areaname, $component, null, $lazyload); } /** * Returns the config var name. * * It depends on whether it is a moodle subsystem or a plugin as plugin-related config should remain in their own scope. * * @access private * @return string Config var path including the plugin (or component) and the varname */ public function get_config_var_name() { if ($this->componenttype === 'core') { // Core subsystems config in core_search and setting name using only [a-zA-Z0-9_]+. $parts = \core_search\manager::extract_areaid_parts($this->areaid); return array('core_search', $parts[0] . '_' . $parts[1]); } // Plugins config in the plugin scope. return array($this->componentname, 'search_' . $this->areaname); } /** * Returns all the search area configuration. * * @return array */ public function get_config() { list($componentname, $varname) = $this->get_config_var_name(); $config = []; $settingnames = self::get_settingnames(); foreach ($settingnames as $name) { $config[$varname . $name] = get_config($componentname, $varname . $name); } // Search areas are enabled by default. if ($config[$varname . '_enabled'] === false) { $config[$varname . '_enabled'] = 1; } return $config; } /** * Return a list of all required setting names. * * @return array */ public static function get_settingnames() { return array('_enabled', '_indexingstart', '_indexingend', '_lastindexrun', '_docsignored', '_docsprocessed', '_recordsprocessed', '_partial'); } /** * Is the search component enabled by the system administrator? * * @return bool */ public function is_enabled() { list($componentname, $varname) = $this->get_config_var_name(); $value = get_config($componentname, $varname . '_enabled'); // Search areas are enabled by default. if ($value === false) { $value = 1; } return (bool)$value; } public function set_enabled($isenabled) { list($componentname, $varname) = $this->get_config_var_name(); return set_config($varname . '_enabled', $isenabled, $componentname); } /** * Gets the length of time spent indexing this area (the last time it was indexed). * * @return int|bool Time in seconds spent indexing this area last time, false if never indexed */ public function get_last_indexing_duration() { list($componentname, $varname) = $this->get_config_var_name(); $start = get_config($componentname, $varname . '_indexingstart'); $end = get_config($componentname, $varname . '_indexingend'); if ($start && $end) { return $end - $start; } else { return false; } } /** * Returns true if this area uses file indexing. * * @return bool */ public function uses_file_indexing() { return false; } /** * Returns a recordset ordered by modification date ASC. * * Each record can include any data self::get_document might need but it must: * - Include an 'id' field: Unique identifier (in this area's scope) of a document to index in the search engine * If the indexed content field can contain embedded files, the 'id' value should match the filearea itemid. * - Only return data modified since $modifiedfrom, including $modifiedform to prevent * some records from not being indexed (e.g. your-timemodified-fieldname >= $modifiedfrom) * - Order the returned data by time modified in ascending order, as \core_search::manager will need to store the modified time * of the last indexed document. * * Since Moodle 3.4, subclasses should instead implement get_document_recordset, which has * an additional context parameter. This function continues to work for implementations which * haven't been updated, or where the context parameter is not required. * * @param int $modifiedfrom * @return \moodle_recordset */ public function get_recordset_by_timestamp($modifiedfrom = 0) { $result = $this->get_document_recordset($modifiedfrom); if ($result === false) { throw new \coding_exception( 'Search area must implement get_document_recordset or get_recordset_by_timestamp'); } return $result; } /** * Returns a recordset containing all items from this area, optionally within the given context, * and including only items modifed from (>=) the specified time. The recordset must be ordered * in ascending order of modified time. * * Each record can include any data self::get_document might need. It must include an 'id' * field,a unique identifier (in this area's scope) of a document to index in the search engine. * If the indexed content field can contain embedded files, the 'id' value should match the * filearea itemid. * * The return value can be a recordset, null (if this area does not provide any results in the * given context and there is no need to do a database query to find out), or false (if this * facility is not currently supported by this search area). * * If this function returns false, then: * - If indexing the entire system (no context restriction) the search indexer will try * get_recordset_by_timestamp instead * - If trying to index a context (e.g. when restoring a course), the search indexer will not * index this area, so that restored content may not be indexed. * * The default implementation returns false, indicating that this facility is not supported and * the older get_recordset_by_timestamp function should be used. * * This function must accept all possible values for the $context parameter. For example, if * you are implementing this function for the forum module, it should still operate correctly * if called with the context for a glossary module, or for the HTML block. (In these cases * where it will not return any data, it may return null.) * * The $context parameter can also be null or the system context; both of these indicate that * all data, without context restriction, should be returned. * * @param int $modifiedfrom Return only records modified after this date * @param \context|null $context Context (null means no context restriction) * @return \moodle_recordset|null|false Recordset / null if no results / false if not supported * @since Moodle 3.4 */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { return false; } /** * Checks if get_document_recordset is supported for this search area. * * For many uses you can simply call get_document_recordset and see if it returns false, but * this function is useful when you don't want to actually call the function right away. */ public function supports_get_document_recordset() { // Easiest way to check this is simply to see if the class has overridden the default // function. $method = new \ReflectionMethod($this, 'get_document_recordset'); return $method->getDeclaringClass()->getName() !== self::class; } /** * Returns the document related with the provided record. * * This method receives a record with the document id and other info returned by get_recordset_by_timestamp * or get_recordset_by_contexts that might be useful here. The idea is to restrict database queries to * minimum as this function will be called for each document to index. As an alternative, use cached data. * * Internally it should use \core_search\document to standarise the documents before sending them to the search engine. * * Search areas should send plain text to the search engine, use the following function to convert any user * input data to plain text: {@link content_to_text} * * Valid keys for the options array are: * indexfiles => File indexing is enabled if true. * lastindexedtime => The last time this area was indexed. 0 if never indexed. * * The lastindexedtime value is not set if indexing a specific context rather than the whole * system. * * @param \stdClass $record A record containing, at least, the indexed document id and a modified timestamp * @param array $options Options for document creation * @return \core_search\document */ abstract public function get_document($record, $options = array()); /** * Returns the document title to display. * * Allow to customize the document title string to display. * * @param \core_search\document $doc * @return string Document title to display in the search results page */ public function get_document_display_title(\core_search\document $doc) { return $doc->get('title'); } /** * Return the context info required to index files for * this search area. * * Should be onerridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array(); return $fileareas; } /** * Files related to the current document are attached, * to the document object ready for indexing by * Global Search. * * The default implementation retrieves all files for * the file areas returned by get_search_fileareas(). * If you need to filter files to specific items per * file area, you will need to override this method * and explicitly provide the items. * * @param document $document The current document * @return void */ public function attach_files($document) { $fileareas = $this->get_search_fileareas(); $contextid = $document->get('contextid'); $component = $this->get_component_name(); $itemid = $document->get('itemid'); foreach ($fileareas as $filearea) { $fs = get_file_storage(); $files = $fs->get_area_files($contextid, $component, $filearea, $itemid, '', false); foreach ($files as $file) { $document->add_stored_file($file); } } } /** * Can the current user see the document. * * @param int $id The internal search area entity id. * @return int manager:ACCESS_xx constant */ abstract public function check_access($id); /** * Returns a url to the document, it might match self::get_context_url(). * * @param \core_search\document $doc * @return \moodle_url */ abstract public function get_doc_url(\core_search\document $doc); /** * Returns a url to the document context. * * @param \core_search\document $doc * @return \moodle_url */ abstract public function get_context_url(\core_search\document $doc); /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context, for data stored at course level. * * The SQL returned will be zero or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in a module table. * * You can pass in a null or system context, which will both return an empty string and no * params. * * Returns an array with two nulls if there can be no results for a course within this context. * * If named parameters are used, these will be named gclcrs0, gclcrs1, etc. The table aliases * used in SQL also all begin with gclcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $coursetable Name of alias for course table e.g. 'c' * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters; both null if no need to query * @throws \coding_exception If called with invalid params */ protected function get_course_level_context_restriction_sql(?\context $context, $coursetable, $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $key1 = 0; $key2 = 1; break; case SQL_PARAMS_NAMED: $param1 = ':gclcrs0'; $param2 = ':gclcrs1'; $key1 = 'gclcrs0'; $key2 = 'gclcrs1'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: // Find all courses within the specified category or any sub-category. $pathmatch = $DB->sql_like('gclcrscc2.path', $DB->sql_concat('gclcrscc1.path', $param2)); $sql = " JOIN {course_categories} gclcrscc1 ON gclcrscc1.id = $param1 JOIN {course_categories} gclcrscc2 ON gclcrscc2.id = $coursetable.category AND (gclcrscc2.id = gclcrscc1.id OR $pathmatch) "; $params[$key1] = $context->instanceid; // Note: This param is a bit annoying as it obviously never changes, but sql_like // throws a debug warning if you pass it anything with quotes in, so it has to be // a bound parameter. $params[$key2] = '/%'; break; case CONTEXT_COURSE: // We just join again against the same course entry and confirm that it has the // same id as the context. $sql = " JOIN {course} gclcrsc ON gclcrsc.id = $coursetable.id AND gclcrsc.id = $param1"; $params[$key1] = $context->instanceid; break; case CONTEXT_BLOCK: case CONTEXT_MODULE: case CONTEXT_USER: // Context cannot contain any courses. return [null, null]; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * Gets a list of all contexts to reindex when reindexing this search area. The list should be * returned in an order that is likely to be suitable when reindexing, for example with newer * contexts first. * * The default implementation simply returns the system context, which will result in * reindexing everything in normal date order (oldest first). * * @return \Iterator Iterator of contexts to reindex */ public function get_contexts_to_reindex() { return new \ArrayIterator([\context_system::instance()]); } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc): document_icon { return new document_icon('i/empty'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_OTHER]; } } templates/form-user-selector-suggestion.mustache000064400000002450152265255700016231 0ustar00{{! 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 . }} {{! @template core_search/form-user-selector-suggestion Moodle template for the list of valid options in an autocomplate form element. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * profileimageurlsmall Url to a small profile image. * fullname Users full name Example context (json): { "id": "1", "fullname": "Kermit Thefrog", "profileimageurlsmall": "https://example.org/img" } }} {{fullname}} templates/result.mustache000064400000006361152265255700011632 0ustar00{{! 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 . }} {{! @template core_search/result Template which shows a search result. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * courseurl * coursefullname * title * docurl * contexturl Optional context variables for this template: * content * userurl * userfullname * description1 * description2 * filename * multiplefiles * filenames * itemid * contextid * userid * timemodified * iconname * iconcomponent * icon * iconurl Example context (json): { "courseurl": "https://example.com/course/view.php?id=2", "coursefullname": "Example course", "title": "Example title", "docurl": "https://example.com/mod/example/view.php?id=4", "content": "I am content", "contexturl": "https://example.com/mod/example/view.php?id=2", "userurl": "https://example.com/user/profile.php?id=3", "userfullname": "Example User Full Name", "multiplefiles": true, "filenames": [ "file1.txt", "file2.txt" ], "iconname": "i/customfield" } }}

{{#iconname}} {{#pix}}{{iconname}}, {{iconcomponent}}{{/pix}} {{/iconname}} {{{title}}}

{{#content}}
{{{content}}}
{{/content}} {{#description1}}
{{{description1}}}
{{/description1}} {{#description2}}
{{{description2}}}
{{/description2}} {{#filename}}
{{#str}}matchingfile, search, {{{filename}}}{{/str}}
{{/filename}} {{#multiplefiles}}
{{#str}}matchingfiles, search{{/str}}
    {{#filenames}}
  • {{.}}
  • {{/filenames}}
{{/multiplefiles}}
templates/index_requests.mustache000064400000007030152265255700013350 0ustar00{{! 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 . }} {{! @template core_search/index_requests Template to provide admin information about the queue of index requests. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * topten * count Optional context variables for this template: * ellipsis Example context (json): { "topten": [ { "id": 42, "timerequested": 123456789, "contextid": 123, "contextlink": "Forum: Tutor group forum", "searcharea": "mod_forum-activity", "areaname": "Forum activities", "partialarea": "mod_forum-activity", "partialareaname": "Forum activities", "partialtime": 123400000, "indexpriority": 100 } ], "total": 1, "ellipsis": true } }} {{#total}}

{{#str}} queueheading, search, {{total}} {{/str}}

{{#topten}} {{/topten}} {{#ellipsis}} {{/ellipsis}}
{{#str}} context, role {{/str}} {{#str}} searcharea, search {{/str}} {{#str}} time {{/str}} {{#str}} progress, search {{/str}} {{#str}} priority, search {{/str}}
{{{contextlink}}} {{#searcharea}} {{areaname}} {{/searcharea}} {{#userdate}} {{timerequested}}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}} {{#partialarea}} {{partialareaname}}: {{/partialarea}} {{#partialtime}} {{#userdate}} {{partialtime}}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}} {{/partialtime}} {{#priorityname}} {{priorityname}} {{/priorityname}} {{^priorityname}} {{indexpriority}} {{/priorityname}}
...
{{/total}} upgrade.txt000064400000016062152265255700006752 0ustar00=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes in /search/*, information provided here is intended especially for developers. === 4.2 === * Support for optional final element returned by engine `add_documents()` implementations is now removed, all six expected returned array elements must be present === 3.10 === * Search indexing now supports sending multiple documents to the server in a batch. This is implemented for the Solr search engine, where it significantly increases performance. For this to work, engines should implement add_document_batch() function and return true to supports_add_document_batch(). There is also an additional parameter returned from add_documents() with the number of batches sent, which is used for the log display. Existing engines should continue to work unmodified. * Search engines can now implement the optional has_alternate_configuration() function to indicate if they provide two different connection configurations (for use when moving between two search engines of the same type). The constructor should also accept a boolean value (true = alternate); passing this to the base class constructor will automatically switch in the alternate configuration settings, provided they begin with 'alternate'. === 3.8 === * Search indexing supports time limits to make the scheduled task run more neatly since 3.4. In order for this to work, search engine plugins will need to implement the 'stopat' parameter if they override the add_documents() function, and return an extra parameter from this function (see base class in engine.php). Unmodified plugins will not work anymore. * New search engine functions delete_index_for_context and delete_index_for_course are called by the search manager to inform the search engine it can remove some documents from its index. (Otherwise, documents from delete courses are never removed unless you reindex.) It is optional for search engines to support these; if they don't implement them then behaviour is unchanged. === 3.7 === * Search areas now have categories and can optionally implement get_category_names method to display search results of the area in the required tab on the search results screen (if this feature is enabled). * Added a new call back search_area_categories. Plugins can implement this method in lib.php and return a list of custom search area categories (\core_search\area_category) and associated with them search areas. This will bring additional custom tabs to the search results screen. * Added \core_search\manager::clean_up_non_existing_area method to clean up removed or renamed search areas. To support that a new adhoc task core\task\clean_up_deleted_search_area_task added. === 3.5 === * Search areas may now optionally implement the get_contexts_to_reindex function (for modules and blocks, see also get_contexts_to_reindex_extra_sql). This allows a search area to customise the order in which it is reindexed when doing a gradual reindex, so as to reindex the most important contexts first. If not implemented, the default behaviour for modules and blocks is to reindex the newest items first; for other types of search area it will just index the whole system context, oldest data first. * Search engines may now implement get_supported_orders function to provide multiple ordering options (other than 'relevance' which is default). If there is more than one order then a choice will be shown to users. (This is an optional feature, existing search engine plugins do not need to be modified in order to continue working.) * Module search areas that wish to support group filtering should set the new optional search document field groupid (note: to remain compatible with earlier versions, do this inside an if statement so that it only happens on 3.4+) and return true to the supports_group_restriction function. See documentation in \core_search\base_mod class and example in \mod_forum\search\post. * When a search engine supports group filtering, the \core_search\manager::search function now accepts the optional 'groupids' parameter in its $data input. This parameter is an array of one or more group IDs. If supplied, only results from those groups will be returned. * Search engine plugins will need to be be modified if they wish to support group filtering. (Search engines should continue to work unmodified, but will not then support group filtering.) The modification steps are: - Implement the new update_schema function to make the schema change (add groupid field). - Ensure that the groupid field is stored correctly when provided in a document while indexing. - Return true to new supports_group_filtering() function. - execute_query should support the new $data->groupids parameter (to allow users to restrict search results to specific groups) and the modified meaning of the second parameter, $accessinfo (to automatically restrict search results users cannot access due to groups). See implementation in Solr search engine. * Search engine plugins can optionally use a new $this->should_skip_schema_check() function to decide when to skip slow schema checking inside the is_server_ready function, improving user performance on the search page. The Solr plugin implements this. * API function \core_search\manager::instance() now includes a $fast parameter to skip schema checks (as above). * Search engines should now implement the 'userids' option to restrict search results to those from specific users, and return true to the new supports_users() function. The supplied Solr search engine includes this feature. If this is not implemented, the search engine will continue to work but the 'Users' search option will not appear. === 3.4 === * Search indexing now supports time limits to make the scheduled task run more neatly. In order for this to work, search engine plugins will need to implement the 'stopat' parameter if they override the add_documents() function, and return an extra parameter from this function (see base class in engine.php). Unmodified plugins will still work, but without supporting time limits. * Search areas should now implement the get_document_recordset function instead of the old get_recordset_by_timestamp API (implement both if the area should work in older Moodle versions as well). The new function is the same as the old one, but has an additional context parameter. There is a helper function get_context_restriction_sql to make this easy to implement; see code in base_activity.php for an example of how to implement this in your search area. (The change was required to make search work after restoring sites. It also allows more flexible reindexing in other cases.) === 3.2 === * Base search area classes have been renamed, please update your search areas to use the classes below: - \core_search\area\base has been renamed to \core_search\base - \core_search\area\base_mod has been renamed to \core_search\base_mod - \core_search\area\base_activity has been renamed to \core_search\base_activity engine/simpledb/db/install.php000064400000006416152265255700012374 0ustar00. /** * Post installation and migration code. * * @package search_simpledb * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; /** * Post installation code. * * @package search_simpledb * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ function xmldb_search_simpledb_install() { global $DB; switch ($DB->get_dbfamily()) { case 'postgres': // There are a few other ways of doing this which avoid the need for individual indexes. $DB->execute("CREATE INDEX {search_simpledb_title} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', title))"); $DB->execute("CREATE INDEX {search_simpledb_content} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', content))"); $DB->execute("CREATE INDEX {search_simpledb_description1} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', description1))"); $DB->execute("CREATE INDEX {search_simpledb_description2} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', description2))"); break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $DB->execute("CREATE FULLTEXT INDEX {search_simpledb_index_index} ON {search_simpledb_index} (title, content, description1, description2)"); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $catalogname = $DB->get_prefix() . 'search_simpledb_catalog'; if (!$DB->record_exists_sql('SELECT * FROM sys.fulltext_catalogs WHERE name = ?', array($catalogname))) { $DB->execute("CREATE FULLTEXT CATALOG {search_simpledb_catalog} WITH ACCENT_SENSITIVITY=OFF"); } if (defined('PHPUNIT_UTIL') and PHPUNIT_UTIL) { // We want manual tracking for phpunit because the fulltext index does get auto populated fast enough. $changetracking = 'MANUAL'; } else { $changetracking = 'AUTO'; } $DB->execute("CREATE FULLTEXT INDEX ON {search_simpledb_index} (title, content, description1, description2) KEY INDEX {searsimpinde_id_pk} ON {search_simpledb_catalog} WITH CHANGE_TRACKING $changetracking"); } break; } } engine/simpledb/db/uninstall.php000064400000003617152265255700012737 0ustar00. /** * Uninstall code. * * @package search_simpledb * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; /** * Plugin uninstall code. * * @package search_simpledb * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ function xmldb_search_simpledb_uninstall() { global $DB; switch ($DB->get_dbfamily()) { case 'postgres': $DB->execute("DROP INDEX {search_simpledb_title}"); $DB->execute("DROP INDEX {search_simpledb_content}"); $DB->execute("DROP INDEX {search_simpledb_description1}"); $DB->execute("DROP INDEX {search_simpledb_description2}"); break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $DB->execute("ALTER TABLE {search_simpledb_index} DROP INDEX {search_simpledb_index_index}"); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $DB->execute("DROP FULLTEXT CATALOG {search_simpledb_catalog}"); } break; } } engine/simpledb/db/upgrade.php000064400000003200152265255700012341 0ustar00. /** * Calculated question type upgrade code. * * @package search_simpledb * @copyright 2022 Renaud Lemaire {@link http://www.cblue.be} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the simpledb search engine. * @param int $oldversion the version we are upgrading from. */ function xmldb_search_simpledb_upgrade($oldversion = 0) { // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } engine/simpledb/db/install.xml000064400000004171152265255700012401 0ustar00
engine/simpledb/lang/en/search_simpledb.php000064400000003667152265255700015015 0ustar00. /** * Strings for component 'search_simpledb'. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Simple search'; $string['privacy:metadata:index'] = 'Indexed contents'; $string['privacy:metadata:index:docid'] = 'Document ID (unique)'; $string['privacy:metadata:index:itemid'] = 'Item identifier (in search area scope)'; $string['privacy:metadata:index:title'] = 'Title'; $string['privacy:metadata:index:content'] = 'Contents'; $string['privacy:metadata:index:contextid'] = 'Document context ID'; $string['privacy:metadata:index:areaid'] = 'Search area ID'; $string['privacy:metadata:index:type'] = 'Document type'; $string['privacy:metadata:index:courseid'] = 'Course ID'; $string['privacy:metadata:index:owneruserid'] = 'Document owner user ID'; $string['privacy:metadata:index:modified'] = 'Last modification time'; $string['privacy:metadata:index:userid'] = 'Document user ID'; $string['privacy:metadata:index:description1'] = 'Extra description field'; $string['privacy:metadata:index:description2'] = 'Extra description field'; $string['searchinfo'] = 'Search queries'; $string['searchinfo_help'] = 'Enter the search query.'; engine/simpledb/version.php000064400000002002152265255700012011 0ustar00. /** * Version info. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->component = 'search_simpledb'; engine/simpledb/classes/privacy/provider.php000064400000021211152265255700015273 0ustar00. /** * Privacy class for requesting user data. * * @package search_simpledb * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb\privacy; defined('MOODLE_INTERNAL') || die(); use core_privacy\local\metadata\collection; use core_privacy\local\request\contextlist; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\transform; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; /** * Provider for the search_simpledb plugin. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_database_table( 'search_simpledb_index', [ 'docid' => 'privacy:metadata:index:docid', 'itemid' => 'privacy:metadata:index:itemid', 'title' => 'privacy:metadata:index:title', 'content' => 'privacy:metadata:index:content', 'contextid' => 'privacy:metadata:index:contextid', 'areaid' => 'privacy:metadata:index:areaid', 'type' => 'privacy:metadata:index:type', 'courseid' => 'privacy:metadata:index:courseid', 'owneruserid' => 'privacy:metadata:index:owneruserid', 'modified' => 'privacy:metadata:index:modified', 'userid' => 'privacy:metadata:index:userid', 'description1' => 'privacy:metadata:index:description1', 'description2' => 'privacy:metadata:index:description2', ], 'privacy:metadata:index' ); return $collection; } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new \core_privacy\local\request\contextlist(); $params = ['userid' => $userid, 'owneruserid' => $userid]; $sql = "SELECT DISTINCT contextid FROM {search_simpledb_index} WHERE (userid = :userid OR owneruserid = :owneruserid)"; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { $context = $userlist->get_context(); $params = [ 'contextid' => $context->id, ]; $sql = "SELECT ssi.userid FROM {search_simpledb_index} ssi WHERE ssi.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT ssi.owneruserid AS userid FROM {search_simpledb_index} ssi WHERE ssi.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, $params); } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { global $DB; // Plugin search_simpledb uses the default document object (core_search\document) which uses FORMAT_PLAIN. $textformat = FORMAT_PLAIN; $userid = $contextlist->get_user()->id; $ctxfields = \context_helper::get_preload_record_columns_sql('ctx'); list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $sql = "SELECT ssi.*, $ctxfields FROM {search_simpledb_index} ssi JOIN {context} ctx ON ctx.id = ssi.contextid WHERE ssi.contextid $contextsql AND (ssi.userid = :userid OR ssi.owneruserid = :owneruserid)"; $params = ['userid' => $userid, 'owneruserid' => $userid] + $contextparams; $records = $DB->get_recordset_sql($sql, $params); foreach ($records as $record) { \context_helper::preload_from_record($record); $context = \context::instance_by_id($record->contextid); $document = (object)[ 'title' => format_string($record->title, true, ['context' => $context]), 'content' => format_text($record->content, $textformat, ['context' => $context]), 'description1' => format_text($record->description1, $textformat, ['context' => $context]), 'description2' => format_text($record->description2, $textformat, ['context' => $context]), 'context' => $context->get_context_name(true, true), 'modified' => transform::datetime($record->modified), ]; $path = [get_string('search', 'search'), $record->docid]; writer::with_context($context)->export_data($path, $document); } $records->close(); } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { global $DB; $DB->delete_records('search_simpledb_index', ['contextid' => $context->id]); if ($context->contextlevel == CONTEXT_USER) { $select = "userid = :userid OR owneruserid = :owneruserid"; $params = ['userid' => $context->instanceid, 'owneruserid' => $context->instanceid]; $DB->delete_records_select('search_simpledb_index', $select, $params); } } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { global $DB; $userid = $contextlist->get_user()->id; list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $select = "contextid $contextsql AND (userid = :userid OR owneruserid = :owneruserid)"; $params = ['userid' => $userid, 'owneruserid' => $userid] + $contextparams; $DB->delete_records_select('search_simpledb_index', $select, $params); } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { global $DB; $context = $userlist->get_context(); list($usersql, $userparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); list($ownersql, $ownerparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); $select = "contextid = :contextid AND (userid {$usersql} OR owneruserid {$ownersql})"; $params = ['contextid' => $context->id] + $userparams + $ownerparams; $DB->delete_records_select('search_simpledb_index', $select, $params); } } engine/simpledb/classes/engine.php000064400000034020152265255700013233 0ustar00. /** * Simple moodle database engine. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb; defined('MOODLE_INTERNAL') || die(); /** * Simple moodle database engine. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine extends \core_search\engine { /** * Total number of available results. * * @var null|int */ protected $totalresults = null; /** * Prepares a SQL query, applies filters and executes it returning its results. * * @throws \core_search\engine_exception * @param stdClass $filters Containing query and filters. * @param array $usercontexts Contexts where the user has access. True if the user can access all contexts. * @param int $limit The maximum number of results to return. * @return \core_search\document[] Results or false if no results */ public function execute_query($filters, $usercontexts, $limit = 0) { global $DB, $USER; $serverstatus = $this->is_server_ready(); if ($serverstatus !== true) { throw new \core_search\engine_exception('engineserverstatus', 'search'); } if (empty($limit)) { $limit = \core_search\manager::MAX_RESULTS; } $params = array(); // To store all conditions we will add to where. $ands = array(); // Get results only available for the current user. $ands[] = '(owneruserid = ? OR owneruserid = ?)'; $params = array_merge($params, array(\core_search\manager::NO_OWNER_ID, $USER->id)); // Restrict it to the context where the user can access, we want this one cached. // If the user can access all contexts $usercontexts value is just true, we don't need to filter // in that case. if ($usercontexts && is_array($usercontexts)) { // Join all area contexts into a single array and implode. $allcontexts = array(); foreach ($usercontexts as $areaid => $areacontexts) { if (!empty($filters->areaids) && !in_array($areaid, $filters->areaids)) { // Skip unused areas. continue; } foreach ($areacontexts as $contextid) { // Ensure they are unique. $allcontexts[$contextid] = $contextid; } } if (empty($allcontexts)) { // This means there are no valid contexts for them, so they get no results. return array(); } list($contextsql, $contextparams) = $DB->get_in_or_equal($allcontexts); $ands[] = 'contextid ' . $contextsql; $params = array_merge($params, $contextparams); } // Course id filter. if (!empty($filters->courseids)) { list($conditionsql, $conditionparams) = $DB->get_in_or_equal($filters->courseids); $ands[] = 'courseid ' . $conditionsql; $params = array_merge($params, $conditionparams); } // Area id filter. if (!empty($filters->areaids)) { list($conditionsql, $conditionparams) = $DB->get_in_or_equal($filters->areaids); $ands[] = 'areaid ' . $conditionsql; $params = array_merge($params, $conditionparams); } if (!empty($filters->title)) { $ands[] = $DB->sql_like('title', '?', false, false); $params[] = $filters->title; } if (!empty($filters->timestart)) { $ands[] = 'modified >= ?'; $params[] = $filters->timestart; } if (!empty($filters->timeend)) { $ands[] = 'modified <= ?'; $params[] = $filters->timeend; } // And finally the main query after applying all AND filters. if (!empty($filters->q)) { switch ($DB->get_dbfamily()) { case 'postgres': $ands[] = "(" . "to_tsvector('simple', title) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', content) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', description1) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', description2) @@ plainto_tsquery('simple', ?)". ")"; $params[] = $filters->q; $params[] = $filters->q; $params[] = $filters->q; $params[] = $filters->q; break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $ands[] = "MATCH (title, content, description1, description2) AGAINST (?)"; $params[] = $filters->q; // Sorry for the hack, but it does not seem that we will have a solution for // this soon (https://bugs.mysql.com/bug.php?id=78485). if ($filters->q === '*') { return array(); } } else { // Clumsy version for mysql versions with no fulltext support. list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $ands[] = "CONTAINS ((title, content, description1, description2), ?)"; // Special treatment for double quotes: // - Puntuation is ignored so we can get rid of them. // - Phrases should be enclosed in double quotation marks. $params[] = '"' . str_replace('"', '', $filters->q) . '"'; } else { // Clumsy version for mysql versions with no fulltext support. list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); } break; default: list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); break; } } // It is limited to $limit, no need to use recordsets. $documents = $DB->get_records_select('search_simpledb_index', implode(' AND ', $ands), $params, 'docid', '*', 0, $limit); // Hopefully database cached results as this applies the same filters than above. $this->totalresults = $DB->count_records_select('search_simpledb_index', implode(' AND ', $ands), $params); $numgranted = 0; // Iterate through the results checking its availability and whether they are available for the user or not. $docs = array(); foreach ($documents as $docdata) { if ($docdata->owneruserid != \core_search\manager::NO_OWNER_ID && $docdata->owneruserid != $USER->id) { // If owneruserid is set, no other user should be able to access this record. continue; } if (!$searcharea = $this->get_search_area($docdata->areaid)) { $this->totalresults--; continue; } // Switch id back to the document id. $docdata->id = $docdata->docid; unset($docdata->docid); $access = $searcharea->check_access($docdata->itemid); switch ($access) { case \core_search\manager::ACCESS_DELETED: $this->delete_by_id($docdata->id); $this->totalresults--; break; case \core_search\manager::ACCESS_DENIED: $this->totalresults--; break; case \core_search\manager::ACCESS_GRANTED: $numgranted++; $docs[] = $this->to_document($searcharea, (array)$docdata); break; } // This should never happen. if ($numgranted >= $limit) { $docs = array_slice($docs, 0, $limit, true); break; } } return $docs; } /** * Adds a document to the search engine. * * This does not commit to the search engine. * * @param \core_search\document $document * @param bool $fileindexing True if file indexing is to be used * @return bool False if the file was skipped or failed, true on success */ public function add_document($document, $fileindexing = false) { global $DB; $doc = (object)$document->export_for_engine(); // Moodle's ids using DML are always autoincremented. $doc->docid = $doc->id; unset($doc->id); $id = $DB->get_field('search_simpledb_index', 'id', array('docid' => $doc->docid)); try { if ($id) { $doc->id = $id; $DB->update_record('search_simpledb_index', $doc); } else { $DB->insert_record('search_simpledb_index', $doc); } } catch (\dml_exception $ex) { debugging('dml error while trying to insert document with id ' . $doc->docid . ': ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } return true; } /** * Deletes the specified document. * * @param string $id The document id to delete * @return void */ public function delete_by_id($id) { global $DB; $DB->delete_records('search_simpledb_index', array('docid' => $id)); } /** * Delete all area's documents. * * @param string $areaid * @return void */ public function delete($areaid = null) { global $DB; if ($areaid) { $DB->delete_records('search_simpledb_index', array('areaid' => $areaid)); } else { $DB->delete_records('search_simpledb_index'); } } /** * Checks that the required table was installed. * * @return true|string Returns true if all good or an error string. */ public function is_server_ready() { global $DB; if (!$DB->get_manager()->table_exists('search_simpledb_index')) { return 'search_simpledb_index table does not exist'; } return true; } /** * It is always installed. * * @return true */ public function is_installed() { return true; } /** * Returns the total results. * * Including skipped results. * * @return int */ public function get_query_total_count() { if (is_null($this->totalresults)) { // This is a just in case as we count total results in execute_query. return \core_search\manager::MAX_RESULTS; } return $this->totalresults; } /** * Returns the default query for db engines. * * @param string $q The query string * @return array SQL string and params list */ protected function get_simple_query($q) { global $DB; $sql = '(' . $DB->sql_like('title', '?', false, false) . ' OR ' . $DB->sql_like('content', '?', false, false) . ' OR ' . $DB->sql_like('description1', '?', false, false) . ' OR ' . $DB->sql_like('description2', '?', false, false) . ')'; // Remove quotes from the query. $q = str_replace('"', '', $q); $params = [ '%' . $q . '%', '%' . $q . '%', '%' . $q . '%', '%' . $q . '%' ]; return array($sql, $params); } /** * Simpledb supports deleting the index for a context. * * @param int $oldcontextid Context that has been deleted * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_context(int $oldcontextid) { global $DB; try { $DB->delete_records('search_simpledb_index', ['contextid' => $oldcontextid]); } catch (\dml_exception $e) { throw new \core_search\engine_exception('dbupdatefailed'); } return true; } /** * Simpledb supports deleting the index for a course. * * @param int $oldcourseid * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_course(int $oldcourseid) { global $DB; try { $DB->delete_records('search_simpledb_index', ['courseid' => $oldcourseid]); } catch (\dml_exception $e) { throw new \core_search\engine_exception('dbupdatefailed'); } return true; } } engine/simpledb/tests/privacy/provider_test.php000064400000031532152265255700016046 0ustar00. /** * Unit tests for privacy. * * @package search_simpledb * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb\privacy; use search_simpledb\privacy\provider; use core_privacy\local\request\transform; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Unit tests for privacy. * * @package search_simpledb * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { /** @var \search_simpledb\engine database engine. */ protected $engine; /** @var \testable_core_search core search class adapted to unit test. */ protected $search; /** @var \core_search_generator core search generator class adapted to unit test. */ protected $generator = null; /** @var \stdClass course record.*/ protected $c1; /** @var \stdClass course record.*/ protected $c2; /** @var \context_course context instance. */ protected $c1context; /** @var \context_course context instance. */ protected $c2context; /** @var \stdClass user record. */ protected $u1; /** @var \stdClass user record. */ protected $u2; public function setUp(): void { global $DB; parent::setUp(); if ($this->requires_manual_index_update()) { // We need to update fulltext index manually, which requires an alter table statement. $this->preventResetByRollback(); } $this->resetAfterTest(); set_config('enableglobalsearch', true); // Inject search_simpledb engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_simpledb\engine(); $this->search = \testable_core_search::instance($this->engine); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->c1 = $this->getDataGenerator()->create_course(); $this->c2 = $this->getDataGenerator()->create_course(); $this->c1context = \context_course::instance($this->c1->id); $this->c2context = \context_course::instance($this->c2->id); $this->u1 = $this->getDataGenerator()->create_user(); $this->u2 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student'); $record = (object)[ 'userid' => $this->u1->id, 'contextid' => $this->c1context->id, 'title' => 'vi', 'content' => 'va', 'description1' => 'san', 'description2' => 'jose' ]; $this->generator->create_record($record); $this->generator->create_record((object)['userid' => $this->u1->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['userid' => $this->u2->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['userid' => $this->u2->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u1->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u1->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['owneruserid' => $this->u2->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u2->id, 'contextid' => $this->c2context->id]); $this->search->index(); $this->setAdminUser(); } /** * tearDown * * @return void */ public function tearDown(): void { // Call parent tearDown() first. parent::tearDown(); // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test fetching contexts for a given user ID. */ public function test_get_contexts_for_userid(): void { // Ensure both contexts are found for both users. $expected = [$this->c1context->id, $this->c2context->id]; sort($expected); // User 1. $contextlist = provider::get_contexts_for_userid($this->u1->id); $this->assertCount(2, $contextlist); $actual = $contextlist->get_contextids(); sort($actual); $this->assertEquals($expected, $actual); // User 2. $contextlist = provider::get_contexts_for_userid($this->u2->id); $this->assertCount(2, $contextlist); $actual = $contextlist->get_contextids(); sort($actual); $this->assertEquals($expected, $actual); } /** * Test fetching user IDs for a given context. */ public function test_get_users_in_context(): void { $component = 'search_simpledb'; // Ensure both users are found for both contexts. $expected = [$this->u1->id, $this->u2->id]; sort($expected); // User 1. $userlist = new \core_privacy\local\request\userlist($this->c1context, $component); provider::get_users_in_context($userlist); $this->assertCount(2, $userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); // User 2. $userlist = new \core_privacy\local\request\userlist($this->c2context, $component); provider::get_users_in_context($userlist); $this->assertCount(2, $userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); } /** * Test export user data. * * @return null */ public function test_export_user_data(): void { global $DB; $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'search_simpledb', [$this->c1context->id]); provider::export_user_data($contextlist); $writer = \core_privacy\local\request\writer::with_context($this->c1context); $this->assertTrue($writer->has_any_data()); $u1c1 = $DB->get_record('search_simpledb_index', ['userid' => $this->u1->id, 'contextid' => $this->c1context->id]); $data = $writer->get_data([get_string('search', 'search'), $u1c1->docid]); $this->assertEquals($this->c1context->get_context_name(true, true), $data->context); $this->assertEquals('vi', $data->title); $this->assertEquals('va', $data->content); $this->assertEquals('san', $data->description1); $this->assertEquals('jose', $data->description2); } /** * Test delete search for context. * * @return null */ public function test_delete_data_for_all_users(): void { global $DB; $this->assertEquals(8, $DB->count_records('search_simpledb_index')); provider::delete_data_for_all_users_in_context($this->c1context); $this->assertEquals(0, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index')); $u2context = \context_user::instance($this->u2->id); provider::delete_data_for_all_users_in_context($u2context); $this->assertEquals(0, $DB->count_records('search_simpledb_index', ['contextid' => $u2context->id])); $this->assertEquals(2, $DB->count_records('search_simpledb_index')); } /** * Test delete search for user. * * @return null */ public function test_delete_data_for_user(): void { global $DB; $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'search_simpledb', [$this->c1context->id]); provider::delete_data_for_user($contextlist); $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); $this->assertEquals(2, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(6, $DB->count_records('search_simpledb_index')); $contextlist = new \core_privacy\local\request\approved_contextlist($this->u2, 'search_simpledb', [$this->c2context->id]); provider::delete_data_for_user($contextlist); $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); $this->assertEquals(2, $DB->count_records('search_simpledb_index', ['contextid' => $this->c2context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index')); } /** * Test deleting data for an approved userlist. */ public function test_delete_data_for_users(): void { global $DB; $component = 'search_simpledb'; $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; // Ensure expected amount of data for both users exists in each context. $this->assertEquals(4, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index', ['contextid' => $this->c2context->id])); // Delete user 1's data in context 1. $approveduserids = [$this->u1->id]; $approvedlist = new \core_privacy\local\request\approved_userlist($this->c1context, $component, $approveduserids); provider::delete_data_for_users($approvedlist); $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); // Ensure user 2's data in context 1 is retained. $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); // Ensure both users' data in context 2 is retained. $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); } /** * Mssql with fulltext support requires manual updates. * * @return bool */ private function requires_manual_index_update() { global $DB; return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported()); } } engine/simpledb/tests/engine_test.php000064400000034765152265255700014017 0ustar00. namespace search_simpledb; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Simple search engine base unit tests. * * @package search_simpledb * @category test * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class engine_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var \ */ protected $engine = null; /** * @var \core_search_generator */ protected $generator = null; /** * Initial stuff. * * @return void */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); if ($this->requires_manual_index_update()) { // We need to update fulltext index manually, which requires an alter table statement. $this->preventResetByRollback(); } set_config('enableglobalsearch', true); // Inject search_simpledb engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_simpledb\engine(); $this->search = \testable_core_search::instance($this->engine); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->setAdminUser(); } /** * tearDown * * @return void */ public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } parent::tearDown(); } /** * Test indexing process. * * @return void */ public function test_index(): void { global $DB; $this->add_mock_search_area(); $record = new \stdClass(); $record->timemodified = time() - 1; $this->generator->create_record($record); // Data gets into the search engine. $this->assertTrue($this->search->index()); // Not anymore as everything was already added. sleep(1); $this->assertFalse($this->search->index()); $this->generator->create_record(); // Indexing again once there is new data. $this->assertTrue($this->search->index()); } /** * Test search filters. * * @return void */ public function test_search(): void { global $USER, $DB; $this->add_mock_search_area(); $this->generator->create_record(); $record = new \stdClass(); $record->title = "Special title"; $this->generator->create_record($record); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); // Based on core_mocksearch\search\indexer. $this->assertEquals($USER->id, $results[0]->get('userid')); $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid')); // Do a test to make sure we aren't searching non-query fields, like areaid. $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->assertCount(0, $this->search->search($querydata)); $querydata->q = 'message'; sleep(1); $beforeadding = time(); sleep(1); $this->generator->create_record(); $this->search->index(); $this->update_index(); // Timestart. $querydata->timestart = $beforeadding; $this->assertCount(1, $this->search->search($querydata)); // Timeend. unset($querydata->timestart); $querydata->timeend = $beforeadding; $this->assertCount(2, $this->search->search($querydata)); // Title. unset($querydata->timeend); $querydata->title = 'Special title'; $this->assertCount(1, $this->search->search($querydata)); // Course IDs. unset($querydata->title); $querydata->courseids = array(SITEID + 1); $this->assertCount(0, $this->search->search($querydata)); $querydata->courseids = array(SITEID); $this->assertCount(3, $this->search->search($querydata)); // Now try some area-id combinations. unset($querydata->courseids); $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $querydata->areaids = array($forumpostareaid); $this->assertCount(0, $this->search->search($querydata)); $querydata->areaids = array($forumpostareaid, $mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array($mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array(); $this->assertCount(3, $this->search->search($querydata)); // Check that index contents get updated. $this->generator->delete_all(); $this->search->index(true); $this->update_index(); unset($querydata->title); $querydata->q = ''; $this->assertCount(0, $this->search->search($querydata)); } /** * Test delete function * * @return void */ public function test_delete(): void { $this->add_mock_search_area(); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); $querydata->q = 'message'; $this->assertCount(2, $this->search->search($querydata)); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->delete_index($areaid); $this->update_index(); $this->assertCount(0, $this->search->search($querydata)); } /** * Test user is allowed. * * @return void */ public function test_alloweduserid(): void { $this->add_mock_search_area(); $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); // Get the doc and insert the default doc. $doc = $area->get_document($record); $this->engine->add_document($doc); $users = array(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); // Add a record that only user 100 can see. $originalid = $doc->get('id'); // Now add a custom doc for each user. foreach ($users as $user) { $doc = $area->get_document($record); $doc->set('id', $originalid.'-'.$user->id); $doc->set('owneruserid', $user->id); $this->engine->add_document($doc); } $this->update_index(); $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'message'; $querydata->title = $doc->get('title'); // We are going to go through each user and see if they get the original and the owned doc. foreach ($users as $user) { $this->setUser($user); $results = $this->search->search($querydata); $this->assertCount(2, $results); $owned = 0; $notowned = 0; // We don't know what order we will get the results in, so we are doing this. foreach ($results as $result) { $owneruserid = $result->get('owneruserid'); if (empty($owneruserid)) { $notowned++; $this->assertEquals(0, $owneruserid); $this->assertEquals($originalid, $result->get('id')); } else { $owned++; $this->assertEquals($user->id, $owneruserid); $this->assertEquals($originalid.'-'.$user->id, $result->get('id')); } } $this->assertEquals(1, $owned); $this->assertEquals(1, $notowned); } // Now test a user with no owned results. $otheruser = $this->getDataGenerator()->create_user(); $this->setUser($otheruser); $results = $this->search->search($querydata); $this->assertCount(1, $results); $this->assertEquals(0, $results[0]->get('owneruserid')); $this->assertEquals($originalid, $results[0]->get('id')); } public function test_delete_by_id(): void { $this->add_mock_search_area(); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); // Then search to make sure they are there. $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); $first = reset($results); $deleteid = $first->get('id'); $this->engine->delete_by_id($deleteid); $this->update_index(); // Check that we don't get a result for it anymore. $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $this->assertNotEquals($deleteid, $result->get('id')); } /** * Tries out deleting data for a context or a course. */ public function test_deleted_contexts_and_courses(): void { // Create some courses and activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'C1', 'summary' => 'xyzzy']); $course1page1 = $generator->create_module('page', ['course' => $course1, 'name' => 'C1P1', 'content' => 'xyzzy']); $generator->create_module('page', ['course' => $course1, 'name' => 'C1P2', 'content' => 'xyzzy']); $course2 = $generator->create_course(['fullname' => 'C2', 'summary' => 'xyzzy']); $course2page = $generator->create_module('page', ['course' => $course2, 'name' => 'C2P', 'content' => 'xyzzy']); $course2pagecontext = \context_module::instance($course2page->cmid); $this->search->index(); // By default we have all data in the index. $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']); // Say we delete the course2pagecontext... $this->engine->delete_index_for_context($course2pagecontext->id); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']); // Now delete the second course... $this->engine->delete_index_for_course($course2->id); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2']); // Finally let's delete using Moodle functions to check that works. Single context first. course_delete_module($course1page1->cmid); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P2']); delete_course($course1, false); $this->assert_raw_index_contents('xyzzy', []); } /** * Check the contents of the index. * * @param string $searchword Word to match within the content field * @param string[] $expected Array of expected result titles, in alphabetical order */ protected function assert_raw_index_contents(string $searchword, array $expected) { global $DB; $results = $DB->get_records_select('search_simpledb_index', $DB->sql_like('content', '?'), ['%' . $searchword . '%'], "id, {$DB->sql_order_by_text('title')}"); $titles = array_map(function($x) { return $x->title; }, $results); sort($titles); $this->assertEquals($expected, $titles); } /** * Adds a mock search area to the search system. */ protected function add_mock_search_area() { $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); } /** * Updates mssql fulltext index if necessary. * * @return bool */ private function update_index() { global $DB; if (!$this->requires_manual_index_update()) { return; } $DB->execute("ALTER FULLTEXT INDEX ON {search_simpledb_index} START UPDATE POPULATION"); $catalogname = $DB->get_prefix() . 'search_simpledb_catalog'; $retries = 0; do { // 0.2 seconds. usleep(200000); $record = $DB->get_record_sql("SELECT FULLTEXTCATALOGPROPERTY(cat.name, 'PopulateStatus') AS [PopulateStatus] FROM sys.fulltext_catalogs AS cat WHERE cat.name = ?", array($catalogname)); $retries++; } while ($retries < 100 && $record->populatestatus != '0'); if ($retries === 100) { // No update after 20 seconds... $this->fail('Sorry, your SQL server fulltext search index is too slow.'); } } /** * Mssql with fulltext support requires manual updates. * * @return bool */ private function requires_manual_index_update() { global $DB; return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported()); } } engine/solr/lang/en/search_solr.php000064400000013447152265255700013352 0ustar00. /** * Strings for component 'search_solr'. * * @package search_solr * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['check_indextoobig'] = 'Index larger than specified size'; $string['check_nocore'] = 'Cannot find index on Solr server'; $string['check_nosize'] = 'Unable to determine index size on Solr server'; $string['check_notconnected'] = 'Cannot connect to Solr server'; $string['check_time'] = 'Server responded with status in {$a}'; $string['connectionerror'] = 'The specified Solr server is not available or the specified index does not exist'; $string['connectionsettings'] = 'Connection settings'; $string['errorcreatingschema'] = 'Error creating the Solr schema: {$a}'; $string['errorvalidatingschema'] = 'Error validating Solr schema: field {$a->fieldname} does not exist. Please follow this link to set up the required fields.'; $string['errorsolr'] = 'The Solr search engine reported an error: {$a}'; $string['extensionerror'] = 'The Apache Solr PHP extension is not installed. Please check the documentation.'; $string['fileindexing'] = 'Enable file indexing'; $string['fileindexing_help'] = 'If your Solr install supports it, this feature allows Moodle to send files to be indexed.
You will need to reindex all site contents after enabling this option for all files to be added.'; $string['fileindexsettings'] = 'File indexing settings'; $string['indexsize'] = 'The index is using {$a} on the Solr server.'; $string['indexsizelimit'] = 'Index size limit'; $string['indexsizelimit_desc'] = 'Shows an error on the status report page if the search index grows larger than this size (in bytes), and a warning if it exceeds 90%. Set to zero for no index size limit.'; $string['maxindexfilekb'] = 'Maximum file size to index (kB)'; $string['maxindexfilekb_help'] = 'Files larger than this number of kilobytes will not be included in search indexing. If set to zero, files of any size will be indexed.'; $string['minimumsolr4'] = 'Solr 4.0 is the minimum version required for Moodle'; $string['missingconfig'] = 'Your Apache Solr server is not yet configured in Moodle.'; $string['multivaluedfield'] = 'Field "{$a}" returned an array instead of a scalar. Please delete the current index, create a new one and run setup_schema.php before indexing data in Solr.'; $string['nodatafromserver'] = 'No data from server'; $string['pluginname'] = 'Solr'; $string['privacy:metadata'] = 'This plugin sends data externally to a linked Solr search engine. It does not store data locally.'; $string['privacy:metadata:data'] = 'Personal data passed through from the search subsystem.'; $string['schemafieldautocreated'] = 'Field "{$a}" already exists in Solr schema. You probably forgot to run this script before indexing data and fields were autocreated by Solr. Please delete the current index, create a new one and run setup_schema.php again before indexing data in Solr.'; $string['schemasetupfromsolr5'] = 'Your Solr server version is lower than 5.0. This script can only set your schema if your Solr version is 5.0 or higher. You need to manually set the fields in your schema according to \\search_solr\\document::get_default_fields_definition().'; $string['searchinfo'] = 'Search queries'; $string['searchinfo_help'] = 'The field to be searched may be specified by prefixing the search query with \'title:\', \'content:\', \'name:\', or \'intro:\'. For example, searching for \'title:news\' would return results with the word \'news\' in the title. Boolean operators (\'AND\', \'OR\', \'NOT\') may be used to combine or exclude keywords. Wildcard characters (\'*\' or \'?\' ) may be used to represent characters in the search query.'; $string['setupok'] = 'The schema is ready to be used.'; $string['solrauthpassword'] = 'HTTP authentication password'; $string['solrauthuser'] = 'HTTP authentication username'; $string['solrindexname'] = 'Index name'; $string['solrhttpconnectionport'] = 'Port'; $string['solrhttpconnectiontimeout'] = 'Timeout'; $string['solrhttpconnectiontimeout_desc'] = 'The HTTP connection timeout is the maximum time in seconds allowed for the HTTP data transfer operation.'; $string['solrinfo'] = 'Solr'; $string['solrnotselected'] = 'Solr engine is not the configured search engine'; $string['solrserverhostname'] = 'Host name'; $string['solrserverhostname_desc'] = 'Domain name of the Solr server.'; $string['solrsecuremode'] = 'Secure mode'; $string['solrsetting'] = 'Solr settings'; $string['solrsslcainfo'] = 'SSL CA certificates name'; $string['solrsslcainfo_desc'] = 'File name holding one or more CA certificates to verify peer with'; $string['solrsslcapath'] = 'SSL CA certificates path'; $string['solrsslcapath_desc'] = 'Directory path holding multiple CA certificates to verify peer with'; $string['solrsslcert'] = 'SSL certificate'; $string['solrsslcert_desc'] = 'File name to a PEM-formatted private certificate'; $string['solrsslkey'] = 'SSL key'; $string['solrsslkey_desc'] = 'File name to a PEM-formatted private key'; $string['solrsslkeypassword'] = 'SSL key password'; $string['solrsslkeypassword_desc'] = 'Password for PEM-formatted private key file'; engine/solr/lib.php000064400000002740152265255700010263 0ustar00. /** * Moodle API functions. * * @package search_solr * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Gets status checks contributed by this plugin. * * If Solr is enabled and indexing is on, returns a check that the connection works. * * @return core\check\check[] Array of status checks */ function search_solr_status_checks(): array { global $CFG; // No checks if search engine is not set to Solr, or is disabled. if (!\core_search\manager::is_indexing_enabled() || $CFG->searchengine !== 'solr') { return []; } // Since it's turned on and set to Solr, configuration really should be OK and we ought to // show if it isn't, so turn on the check. return [new \search_solr\check\connection()]; } engine/solr/version.php000064400000001733152265255700011203 0ustar00. /** * Version info. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->component = 'search_solr'; engine/solr/classes/privacy/provider.php000064400000007767152265255700014477 0ustar00. /** * Privacy class for requesting user data. * * @package search_solr * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr\privacy; defined('MOODLE_INTERNAL') || die(); use core_privacy\local\metadata\collection; use core_privacy\local\request\contextlist; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\userlist; /** * Provider for the search_solr plugin. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This search engine plugin does not store any data itself. // It has no database tables, and it purely acts as a conduit, sending data externally. // This plugin is capable of determining which users have data within it. \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { return $collection->add_external_location_link('solr', ['data' => 'privacy:metadata:data'], 'privacy:metadata'); } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } } engine/solr/classes/engine.php000064400000211702152265255700012417 0ustar00. /** * Solr engine. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); /** * Solr engine. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine extends \core_search\engine { /** * @var string The date format used by solr. */ const DATE_FORMAT = 'Y-m-d\TH:i:s\Z'; /** * @var int Commit documents interval (number of miliseconds). */ const AUTOCOMMIT_WITHIN = 15000; /** * The maximum number of results to fetch at a time. */ const QUERY_SIZE = 120; /** * Highlighting fragsize. Slightly larger than output size (500) to allow for ... appending. */ const FRAG_SIZE = 510; /** * Marker for the start of a highlight. */ const HIGHLIGHT_START = '@@HI_S@@'; /** * Marker for the end of a highlight. */ const HIGHLIGHT_END = '@@HI_E@@'; /** @var float Boost value for matching course in location-ordered searches */ const COURSE_BOOST = 1; /** @var float Boost value for matching context (in addition to course boost) */ const CONTEXT_BOOST = 0.5; /** * @var \SolrClient */ protected $client = null; /** * @var bool True if we should reuse SolrClients, false if not. */ protected $cacheclient = true; /** * @var \curl Direct curl object. */ protected $curl = null; /** * @var array Fields that can be highlighted. */ protected $highlightfields = array('title', 'content', 'description1', 'description2'); /** * @var int Number of total docs reported by Sorl for the last query. */ protected $totalenginedocs = 0; /** * @var int Number of docs we have processed for the last query. */ protected $processeddocs = 0; /** * @var int Number of docs that have been skipped while processing the last query. */ protected $skippeddocs = 0; /** * Solr server major version. * * @var int */ protected $solrmajorversion = null; /** * Initialises the search engine configuration. * * @param bool $alternateconfiguration If true, use alternate configuration settings * @return void */ public function __construct(bool $alternateconfiguration = false) { parent::__construct($alternateconfiguration); $curlversion = curl_version(); if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) { // There is a flaw with curl 7.35.0 that causes problems with client reuse. $this->cacheclient = false; } } /** * Prepares a Solr query, applies filters and executes it returning its results. * * @throws \core_search\engine_exception * @param \stdClass $filters Containing query and filters. * @param \stdClass $accessinfo Information about areas user can access. * @param int $limit The maximum number of results to return. * @return \core_search\document[] Results or false if no results */ public function execute_query($filters, $accessinfo, $limit = 0) { global $USER; if (empty($limit)) { $limit = \core_search\manager::MAX_RESULTS; } // If there is any problem we trigger the exception as soon as possible. $client = $this->get_search_client(); // Create the query object. $query = $this->create_user_query($filters, $accessinfo); // If the query cannot have results, return none. if (!$query) { return []; } // We expect good match rates, so for our first get, we will get a small number of records. // This significantly speeds solr response time for first few pages. $query->setRows(min($limit * 3, static::QUERY_SIZE)); $response = $this->get_query_response($query); // Get count data out of the response, and reset our counters. list($included, $found) = $this->get_response_counts($response); $this->totalenginedocs = $found; $this->processeddocs = 0; $this->skippeddocs = 0; if ($included == 0 || $this->totalenginedocs == 0) { // No results. return array(); } // Get valid documents out of the response. $results = $this->process_response($response, $limit); // We have processed all the docs in the response at this point. $this->processeddocs += $included; // If we haven't reached the limit, and there are more docs left in Solr, lets keep trying. while (count($results) < $limit && ($this->totalenginedocs - $this->processeddocs) > 0) { // Offset the start of the query, and since we are making another call, get more per call. $query->setStart($this->processeddocs); $query->setRows(static::QUERY_SIZE); $response = $this->get_query_response($query); list($included, $found) = $this->get_response_counts($response); if ($included == 0 || $found == 0) { // No new results were found. Found being empty would be weird, so we will just return. return $results; } $this->totalenginedocs = $found; // Get the new response docs, limiting to remaining we need, then add it to the end of the results array. $newdocs = $this->process_response($response, $limit - count($results)); $results = array_merge($results, $newdocs); // Add to our processed docs count. $this->processeddocs += $included; } return $results; } /** * Takes a query and returns the response in SolrObject format. * * @param SolrQuery $query Solr query object. * @return SolrObject|false Response document or false on error. */ protected function get_query_response($query) { try { return $this->get_search_client()->query($query)->getResponse(); } catch (\SolrClientException $ex) { debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER); $this->queryerror = $ex->getMessage(); return false; } catch (\SolrServerException $ex) { debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER); $this->queryerror = $ex->getMessage(); return false; } } /** * Returns the total number of documents available for the most recently call to execute_query. * * @return int */ public function get_query_total_count() { // Return the total engine count minus the docs we have determined are bad. return $this->totalenginedocs - $this->skippeddocs; } /** * Returns count information for a provided response. Will return 0, 0 for invalid or empty responses. * * @param SolrDocument $response The response document from Solr. * @return array A two part array. First how many response docs are in the response. * Second, how many results are vailable in the engine. */ protected function get_response_counts($response) { $found = 0; $included = 0; if (isset($response->grouped->solr_filegroupingid->ngroups)) { // Get the number of results for file grouped queries. $found = $response->grouped->solr_filegroupingid->ngroups; $included = count($response->grouped->solr_filegroupingid->groups); } else if (isset($response->response->numFound)) { // Get the number of results for standard queries. $found = $response->response->numFound; if ($found > 0 && is_array($response->response->docs)) { $included = count($response->response->docs); } } return array($included, $found); } /** * Prepares a new query object with needed limits, filters, etc. * * @param \stdClass $filters Containing query and filters. * @param \stdClass $accessinfo Information about contexts the user can access * @return \SolrDisMaxQuery|null Query object or null if they can't get any results */ protected function create_user_query($filters, $accessinfo) { global $USER; // Let's keep these changes internal. $data = clone $filters; $query = new \SolrDisMaxQuery(); $this->set_query($query, self::replace_underlines($data->q)); $this->add_fields($query); // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters // we are really interested in caching contexts filters instead. if (!empty($data->title)) { $query->addFilterQuery('{!field cache=false f=title}' . $data->title); } if (!empty($data->areaids)) { // If areaids are specified, we want to get any that match. $query->addFilterQuery('{!cache=false}areaid:(' . implode(' OR ', $data->areaids) . ')'); } if (!empty($data->courseids)) { $query->addFilterQuery('{!cache=false}courseid:(' . implode(' OR ', $data->courseids) . ')'); } if (!empty($data->groupids)) { $query->addFilterQuery('{!cache=false}groupid:(' . implode(' OR ', $data->groupids) . ')'); } if (!empty($data->userids)) { $query->addFilterQuery('{!cache=false}userid:(' . implode(' OR ', $data->userids) . ')'); } if (!empty($data->timestart) or !empty($data->timeend)) { if (empty($data->timestart)) { $data->timestart = '*'; } else { $data->timestart = \search_solr\document::format_time_for_engine($data->timestart); } if (empty($data->timeend)) { $data->timeend = '*'; } else { $data->timeend = \search_solr\document::format_time_for_engine($data->timeend); } // No cache. $query->addFilterQuery('{!cache=false}modified:[' . $data->timestart . ' TO ' . $data->timeend . ']'); } // Restrict to users who are supposed to be able to see a particular result. $query->addFilterQuery('owneruserid:(' . \core_search\manager::NO_OWNER_ID . ' OR ' . $USER->id . ')'); // And finally restrict it to the context where the user can access, we want this one cached. // If the user can access all contexts $usercontexts value is just true, we don't need to filter // in that case. if (!$accessinfo->everything && is_array($accessinfo->usercontexts)) { // Join all area contexts into a single array and implode. $allcontexts = array(); foreach ($accessinfo->usercontexts as $areaid => $areacontexts) { if (!empty($data->areaids) && !in_array($areaid, $data->areaids)) { // Skip unused areas. continue; } foreach ($areacontexts as $contextid) { // Ensure they are unique. $allcontexts[$contextid] = $contextid; } } if (empty($allcontexts)) { // This means there are no valid contexts for them, so they get no results. return null; } $query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')'); } if (!$accessinfo->everything && $accessinfo->separategroupscontexts) { // Add another restriction to handle group ids. If there are any contexts using separate // groups, then results in that context will not show unless you belong to the group. // (Note: Access all groups is taken care of earlier, when computing these arrays.) // This special exceptions list allows for particularly pig-headed developers to create // multiple search areas within the same module, where one of them uses separate // groups and the other uses visible groups. It is a little inefficient, but this should // be rare. $exceptions = ''; if ($accessinfo->visiblegroupscontextsareas) { foreach ($accessinfo->visiblegroupscontextsareas as $contextid => $areaids) { $exceptions .= ' OR (contextid:' . $contextid . ' AND areaid:(' . implode(' OR ', $areaids) . '))'; } } if ($accessinfo->usergroups) { // Either the document has no groupid, or the groupid is one that the user // belongs to, or the context is not one of the separate groups contexts. $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' . 'groupid:(' . implode(' OR ', $accessinfo->usergroups) . ') OR ' . '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' . $exceptions); } else { // Either the document has no groupid, or the context is not a restricted one. $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' . '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' . $exceptions); } } if ($this->file_indexing_enabled()) { // Now group records by solr_filegroupingid. Limit to 3 results per group. $query->setGroup(true); $query->setGroupLimit(3); $query->setGroupNGroups(true); $query->addGroupField('solr_filegroupingid'); } else { // Make sure we only get text files, in case the index has pre-existing files. $query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT); } // If ordering by location, add in boost for the relevant course or context ids. if (!empty($filters->order) && $filters->order === 'location') { $coursecontext = $filters->context->get_course_context(); $query->addBoostQuery('courseid', $coursecontext->instanceid, self::COURSE_BOOST); if ($filters->context->contextlevel !== CONTEXT_COURSE) { // If it's a block or activity, also add a boost for the specific context id. $query->addBoostQuery('contextid', $filters->context->id, self::CONTEXT_BOOST); } } return $query; } /** * Prepares a new query by setting the query, start offset and rows to return. * * @param SolrQuery $query * @param object $q Containing query and filters. */ protected function set_query($query, $q) { // Set hightlighting. $query->setHighlight(true); foreach ($this->highlightfields as $field) { $query->addHighlightField($field); } $query->setHighlightFragsize(static::FRAG_SIZE); $query->setHighlightSimplePre(self::HIGHLIGHT_START); $query->setHighlightSimplePost(self::HIGHLIGHT_END); $query->setHighlightMergeContiguous(true); $query->setQuery($q); // A reasonable max. $query->setRows(static::QUERY_SIZE); } /** * Sets fields to be returned in the result. * * @param SolrDisMaxQuery|SolrQuery $query object. */ public function add_fields($query) { $documentclass = $this->get_document_classname(); $fields = $documentclass::get_default_fields_definition(); $dismax = false; if ($query instanceof \SolrDisMaxQuery) { $dismax = true; } foreach ($fields as $key => $field) { $query->addField($key); if ($dismax && !empty($field['mainquery'])) { // Add fields the main query should be run against. // Due to a regression in the PECL solr extension, https://bugs.php.net/bug.php?id=72740, // a boost value is required, even if it is optional; to avoid boosting one among other fields, // the explicit boost value will be the default one, for every field. $query->addQueryField($key, 1); } } } /** * Finds the key common to both highlighing and docs array returned from response. * @param object $response containing results. */ public function add_highlight_content($response) { if (!isset($response->highlighting)) { // There is no highlighting to add. return; } $highlightedobject = $response->highlighting; foreach ($response->response->docs as $doc) { $x = $doc->id; $highlighteddoc = $highlightedobject->$x; $this->merge_highlight_field_values($doc, $highlighteddoc); } } /** * Adds the highlighting array values to docs array values. * * @throws \core_search\engine_exception * @param object $doc containing the results. * @param object $highlighteddoc containing the highlighted results values. */ public function merge_highlight_field_values($doc, $highlighteddoc) { foreach ($this->highlightfields as $field) { if (!empty($doc->$field)) { // Check that the returned value is not an array. No way we can make this work with multivalued solr fields. if (is_array($doc->{$field})) { throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $field); } if (!empty($highlighteddoc->$field)) { // Replace by the highlighted result. $doc->$field = reset($highlighteddoc->$field); } } } } /** * Filters the response on Moodle side. * * @param SolrObject $response Solr object containing the response return from solr server. * @param int $limit The maximum number of results to return. 0 for all. * @param bool $skipaccesscheck Don't use check_access() on results. Only to be used when results have known access. * @return array $results containing final results to be displayed. */ protected function process_response($response, $limit = 0, $skipaccesscheck = false) { global $USER; if (empty($response)) { return array(); } if (isset($response->grouped)) { return $this->grouped_files_process_response($response, $limit); } $userid = $USER->id; $noownerid = \core_search\manager::NO_OWNER_ID; $numgranted = 0; if (!$docs = $response->response->docs) { return array(); } $out = array(); if (!empty($response->response->numFound)) { $this->add_highlight_content($response); // Iterate through the results checking its availability and whether they are available for the user or not. foreach ($docs as $key => $docdata) { if ($docdata['owneruserid'] != $noownerid && $docdata['owneruserid'] != $userid) { // If owneruserid is set, no other user should be able to access this record. continue; } if (!$searcharea = $this->get_search_area($docdata->areaid)) { continue; } $docdata = $this->standarize_solr_obj($docdata); if ($skipaccesscheck) { $access = \core_search\manager::ACCESS_GRANTED; } else { $access = $searcharea->check_access($docdata['itemid']); } switch ($access) { case \core_search\manager::ACCESS_DELETED: $this->delete_by_id($docdata['id']); // Remove one from our processed and total counters, since we promptly deleted. $this->processeddocs--; $this->totalenginedocs--; break; case \core_search\manager::ACCESS_DENIED: $this->skippeddocs++; break; case \core_search\manager::ACCESS_GRANTED: $numgranted++; // Add the doc. $out[] = $this->to_document($searcharea, $docdata); break; } // Stop when we hit our limit. if (!empty($limit) && count($out) >= $limit) { break; } } } return $out; } /** * Processes grouped file results into documents, with attached matching files. * * @param SolrObject $response The response returned from solr server * @param int $limit The maximum number of results to return. 0 for all. * @return array Final results to be displayed. */ protected function grouped_files_process_response($response, $limit = 0) { // If we can't find the grouping, or there are no matches in the grouping, return empty. if (!isset($response->grouped->solr_filegroupingid) || empty($response->grouped->solr_filegroupingid->matches)) { return array(); } $numgranted = 0; $orderedids = array(); $completedocs = array(); $incompletedocs = array(); $highlightingobj = $response->highlighting; // Each group represents a "master document". $groups = $response->grouped->solr_filegroupingid->groups; foreach ($groups as $group) { $groupid = $group->groupValue; $groupdocs = $group->doclist->docs; $firstdoc = reset($groupdocs); if (!$searcharea = $this->get_search_area($firstdoc->areaid)) { // Well, this is a problem. continue; } // Check for access. $access = $searcharea->check_access($firstdoc->itemid); switch ($access) { case \core_search\manager::ACCESS_DELETED: // If deleted from Moodle, delete from index and then continue. $this->delete_by_id($firstdoc->id); // Remove one from our processed and total counters, since we promptly deleted. $this->processeddocs--; $this->totalenginedocs--; continue 2; break; case \core_search\manager::ACCESS_DENIED: // This means we should just skip for the current user. $this->skippeddocs++; continue 2; break; } $numgranted++; $maindoc = false; $fileids = array(); // Seperate the main document and any files returned. foreach ($groupdocs as $groupdoc) { if ($groupdoc->id == $groupid) { $maindoc = $groupdoc; } else if (isset($groupdoc->solr_fileid)) { $fileids[] = $groupdoc->solr_fileid; } } // Store the id of this group, in order, for later merging. $orderedids[] = $groupid; if (!$maindoc) { // We don't have the main doc, store what we know for later building. $incompletedocs[$groupid] = $fileids; } else { if (isset($highlightingobj->$groupid)) { // Merge the highlighting for this doc. $this->merge_highlight_field_values($maindoc, $highlightingobj->$groupid); } $docdata = $this->standarize_solr_obj($maindoc); $doc = $this->to_document($searcharea, $docdata); // Now we need to attach the result files to the doc. foreach ($fileids as $fileid) { $doc->add_stored_file($fileid); } $completedocs[$groupid] = $doc; } if (!empty($limit) && $numgranted >= $limit) { // We have hit the max results, we will just ignore the rest. break; } } $incompletedocs = $this->get_missing_docs($incompletedocs); $out = array(); // Now merge the complete and incomplete documents, in results order. foreach ($orderedids as $docid) { if (isset($completedocs[$docid])) { $out[] = $completedocs[$docid]; } else if (isset($incompletedocs[$docid])) { $out[] = $incompletedocs[$docid]; } } return $out; } /** * Retreive any missing main documents and attach provided files. * * The missingdocs array should be an array, indexed by document id, of main documents we need to retrieve. The value * associated to the key should be an array of stored_files or stored file ids to attach to the result document. * * Return array also indexed by document id. * * @param array() $missingdocs An array, indexed by document id, with arrays of files/ids to attach. * @return document[] */ protected function get_missing_docs($missingdocs) { if (empty($missingdocs)) { return array(); } $docids = array_keys($missingdocs); // Build a custom query that will get all the missing documents. $query = new \SolrQuery(); $this->set_query($query, '*'); $this->add_fields($query); $query->setRows(count($docids)); $query->addFilterQuery('{!cache=false}id:(' . implode(' OR ', $docids) . ')'); $response = $this->get_query_response($query); // We know the missing docs have already been checked for access, so don't recheck. $results = $this->process_response($response, 0, true); $out = array(); foreach ($results as $result) { $resultid = $result->get('id'); if (!isset($missingdocs[$resultid])) { // We got a result we didn't expect. Skip it. continue; } // Attach the files. foreach ($missingdocs[$resultid] as $filedoc) { $result->add_stored_file($filedoc); } $out[$resultid] = $result; } return $out; } /** * Returns a standard php array from a \SolrObject instance. * * @param \SolrObject $obj * @return array The returned document as an array. */ public function standarize_solr_obj(\SolrObject $obj) { $properties = $obj->getPropertyNames(); $docdata = array(); foreach($properties as $name) { // http://php.net/manual/en/solrobject.getpropertynames.php#98018. $name = trim($name); $docdata[$name] = $obj->offsetGet($name); } return $docdata; } /** * Adds a document to the search engine. * * This does not commit to the search engine. * * @param document $document * @param bool $fileindexing True if file indexing is to be used * @return bool */ public function add_document($document, $fileindexing = false) { $docdata = $document->export_for_engine(); if (!$this->add_solr_document($docdata)) { return false; } if ($fileindexing) { // This will take care of updating all attached files in the index. $this->process_document_files($document); } return true; } /** * Adds a batch of documents to the engine at once. * * @param \core_search\document[] $documents Documents to add * @param bool $fileindexing If true, indexes files (these are done one at a time) * @return int[] Array of three elements: successfully processed, failed processed, batch count */ public function add_document_batch(array $documents, bool $fileindexing = false): array { $docdatabatch = []; foreach ($documents as $document) { $docdatabatch[] = $document->export_for_engine(); } $resultcounts = $this->add_solr_documents($docdatabatch); // Files are processed one document at a time (if there are files it's slow anyway). if ($fileindexing) { foreach ($documents as $document) { // This will take care of updating all attached files in the index. $this->process_document_files($document); } } return $resultcounts; } /** * Replaces underlines at edges of words in the content with spaces. * * For example '_frogs_' will become 'frogs', '_frogs and toads_' will become 'frogs and toads', * and 'frogs_and_toads' will be left as 'frogs_and_toads'. * * The reason for this is that for italic content_to_text puts _italic_ underlines at the start * and end of the italicised phrase (not between words). Solr treats underlines as part of the * word, which means that if you search for a word in italic then you can't find it. * * @param string $str String to replace * @return string Replaced string */ protected static function replace_underlines(string $str): string { return preg_replace('~\b_|_\b~', '', $str); } /** * Creates a Solr document object. * * @param array $doc Array of document fields * @return \SolrInputDocument Created document */ protected function create_solr_document(array $doc): \SolrInputDocument { $solrdoc = new \SolrInputDocument(); // Replace underlines in the content with spaces. The reason for this is that for italic // text, content_to_text puts _italic_ underlines. Solr treats underlines as part of the // word, which means that if you search for a word in italic then you can't find it. if (array_key_exists('content', $doc)) { $doc['content'] = self::replace_underlines($doc['content']); } // Set all the fields. foreach ($doc as $field => $value) { $solrdoc->addField($field, $value); } return $solrdoc; } /** * Adds a text document to the search engine. * * @param array $doc * @return bool */ protected function add_solr_document($doc) { $solrdoc = $this->create_solr_document($doc); try { $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN); return true; } catch (\SolrClientException $e) { debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER); } catch (\SolrServerException $e) { // We only use the first line of the message, as it's a fully java stacktrace behind it. $msg = strtok($e->getMessage(), "\n"); debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER); } return false; } /** * Adds multiple text documents to the search engine. * * @param array $docs Array of documents (each an array of fields) to add * @return int[] Array of success, failure, batch count * @throws \core_search\engine_exception */ protected function add_solr_documents(array $docs): array { $solrdocs = []; foreach ($docs as $doc) { $solrdocs[] = $this->create_solr_document($doc); } try { // Add documents in a batch and report that they all succeeded. $this->get_search_client()->addDocuments($solrdocs, true, static::AUTOCOMMIT_WITHIN); return [count($solrdocs), 0, 1]; } catch (\SolrClientException $e) { // If there is an exception, fall through... $donothing = true; } catch (\SolrServerException $e) { // If there is an exception, fall through... $donothing = true; } // When there is an error, we fall back to adding them individually so that we can report // which document(s) failed. Since it overwrites, adding the successful ones multiple // times won't hurt. $success = 0; $failure = 0; $batches = 0; foreach ($docs as $doc) { $result = $this->add_solr_document($doc); $batches++; if ($result) { $success++; } else { $failure++; } } return [$success, $failure, $batches]; } /** * Index files attached to the docuemnt, ensuring the index matches the current document files. * * For documents that aren't known to be new, we check the index for existing files. * - New files we will add. * - Existing and unchanged files we will skip. * - File that are in the index but not on the document will be deleted from the index. * - Files that have changed will be re-indexed. * * @param document $document */ protected function process_document_files($document) { if (!$this->file_indexing_enabled()) { return; } // Maximum rows to process at a time. $rows = 500; // Get the attached files. $files = $document->get_files(); // If this isn't a new document, we need to check the exiting indexed files. if (!$document->get_is_new()) { // We do this progressively, so we can handle lots of files cleanly. list($numfound, $indexedfiles) = $this->get_indexed_files($document, 0, $rows); $count = 0; $idstodelete = array(); do { // Go through each indexed file. We want to not index any stored and unchanged ones, delete any missing ones. foreach ($indexedfiles as $indexedfile) { $fileid = $indexedfile->solr_fileid; if (isset($files[$fileid])) { // Check for changes that would mean we need to re-index the file. If so, just leave in $files. // Filelib does not guarantee time modified is updated, so we will check important values. if ($indexedfile->modified != $files[$fileid]->get_timemodified()) { continue; } if (strcmp($indexedfile->title, $files[$fileid]->get_filename()) !== 0) { continue; } if ($indexedfile->solr_filecontenthash != $files[$fileid]->get_contenthash()) { continue; } if ($indexedfile->solr_fileindexstatus == document::INDEXED_FILE_FALSE && $this->file_is_indexable($files[$fileid])) { // This means that the last time we indexed this file, filtering blocked it. // Current settings say it is indexable, so we will allow it to be indexed. continue; } // If the file is already indexed, we can just remove it from the files array and skip it. unset($files[$fileid]); } else { // This means we have found a file that is no longer attached, so we need to delete from the index. // We do it later, since this is progressive, and it could reorder results. $idstodelete[] = $indexedfile->id; } } $count += $rows; if ($count < $numfound) { // If we haven't hit the total count yet, fetch the next batch. list($numfound, $indexedfiles) = $this->get_indexed_files($document, $count, $rows); } } while ($count < $numfound); // Delete files that are no longer attached. foreach ($idstodelete as $id) { // We directly delete the item using the client, as the engine delete_by_id won't work on file docs. $this->get_search_client()->deleteById($id); } } // Now we can actually index all the remaining files. foreach ($files as $file) { $this->add_stored_file($document, $file); } } /** * Get the currently indexed files for a particular document, returns the total count, and a subset of files. * * @param document $document * @param int $start The row to start the results on. Zero indexed. * @param int $rows The number of rows to fetch * @return array A two element array, the first is the total number of availble results, the second is an array * of documents for the current request. */ protected function get_indexed_files($document, $start = 0, $rows = 500) { // Build a custom query that will get any document files that are in our solr_filegroupingid. $query = new \SolrQuery(); // We want to get all file records tied to a document. // For efficiency, we are building our own, stripped down, query. $query->setQuery('*'); $query->setRows($rows); $query->setStart($start); // We want a consistent sorting. $query->addSortField('id'); // We only want the bare minimum of fields. $query->addField('id'); $query->addField('modified'); $query->addField('title'); $query->addField('solr_fileid'); $query->addField('solr_filecontenthash'); $query->addField('solr_fileindexstatus'); $query->addFilterQuery('{!cache=false}solr_filegroupingid:(' . $document->get('id') . ')'); $query->addFilterQuery('type:' . \core_search\manager::TYPE_FILE); $response = $this->get_query_response($query); if (empty($response->response->numFound)) { return array(0, array()); } return array($response->response->numFound, $this->convert_file_results($response)); } /** * A very lightweight handler for getting information about already indexed files from a Solr response. * * @param SolrObject $responsedoc A Solr response document * @return stdClass[] An array of objects that contain the basic information for file processing. */ protected function convert_file_results($responsedoc) { if (!$docs = $responsedoc->response->docs) { return array(); } $out = array(); foreach ($docs as $doc) { // Copy the bare minimim needed info. $result = new \stdClass(); $result->id = $doc->id; $result->modified = document::import_time_from_engine($doc->modified); $result->title = $doc->title; $result->solr_fileid = $doc->solr_fileid; $result->solr_filecontenthash = $doc->solr_filecontenthash; $result->solr_fileindexstatus = $doc->solr_fileindexstatus; $out[] = $result; } return $out; } /** * Adds a file to the search engine. * * Notes about Solr and Tika indexing. We do not send the mime type, only the filename. * Tika has much better content type detection than Moodle, and we will have many more doc failures * if we try to send mime types. * * @param document $document * @param \stored_file $storedfile * @return void */ protected function add_stored_file($document, $storedfile) { $filedoc = $document->export_file_for_engine($storedfile); if (!$this->file_is_indexable($storedfile)) { // For files that we don't consider indexable, we will still place a reference in the search engine. $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_FALSE; $this->add_solr_document($filedoc); return; } $curl = $this->get_curl_object(); $url = $this->get_connection_url('/update/extract'); // Return results as XML. $url->param('wt', 'xml'); // This will prevent solr from automatically making fields for every tika output. $url->param('uprefix', 'ignored_'); // Control how content is captured. This will keep our file content clean of non-important metadata. $url->param('captureAttr', 'true'); // Move the content to a field for indexing. $url->param('fmap.content', 'solr_filecontent'); // These are common fields that matches the standard *_point dynamic field and causes an error. $url->param('fmap.media_white_point', 'ignored_mwp'); $url->param('fmap.media_black_point', 'ignored_mbp'); // Copy each key to the url with literal. // We place in a temp name then copy back to the true field, which prevents errors or Tika overwriting common field names. foreach ($filedoc as $key => $value) { // This will take any fields from tika that match our schema and discard them, so they don't overwrite ours. $url->param('fmap.'.$key, 'ignored_'.$key); // Place data in a tmp field. $url->param('literal.mdltmp_'.$key, $value); // Then move to the final field. $url->param('fmap.mdltmp_'.$key, $key); } // This sets the true filename for Tika. $url->param('resource.name', $storedfile->get_filename()); // A giant block of code that is really just error checking around the curl request. try { // We have to post the file directly in binary data (not using multipart) to avoid // Solr bug SOLR-15039 which can cause incorrect data when you use multipart upload. // Note this loads the whole file into memory; see limit in file_is_indexable(). $result = $curl->post($url->out(false), $storedfile->get_content()); $code = $curl->get_errno(); $info = $curl->get_info(); // Now error handling. It is just informational, since we aren't tracking per file/doc results. if ($code != 0) { // This means an internal cURL error occurred error is in result. $message = 'Curl error '.$code.' while indexing file with document id '.$filedoc['id'].': '.$result.'.'; debugging($message, DEBUG_DEVELOPER); } else if (isset($info['http_code']) && ($info['http_code'] !== 200)) { // Unexpected HTTP response code. $message = 'Error while indexing file with document id '.$filedoc['id']; // Try to get error message out of msg or title if it exists. if (preg_match('|]*name="msg"[^>]*>(.*?)|i', $result, $matches)) { $message .= ': '.$matches[1]; } else if (preg_match('|]*>([^>]*)|i', $result, $matches)) { $message .= ': '.$matches[1]; } // This is a common error, happening whenever a file fails to index for any reason, so we will make it quieter. if (CLI_SCRIPT && !PHPUNIT_TEST) { mtrace($message); } } else { // Check for the expected status field. if (preg_match('|]*name="status"[^>]*>(\d*)|i', $result, $matches)) { // Now check for the expected status of 0, if not, error. if ((int)$matches[1] !== 0) { $message = 'Unexpected Solr status code '.(int)$matches[1]; $message .= ' while indexing file with document id '.$filedoc['id'].'.'; debugging($message, DEBUG_DEVELOPER); } else { // The document was successfully indexed. return; } } else { // We received an unprocessable response. $message = 'Unexpected Solr response while indexing file with document id '.$filedoc['id'].': '; $message .= strtok($result, "\n"); debugging($message, DEBUG_DEVELOPER); } } } catch (\Exception $e) { // There was an error, but we are not tracking per-file success, so we just continue on. debugging('Unknown exception while indexing file "'.$storedfile->get_filename().'".', DEBUG_DEVELOPER); } // If we get here, the document was not indexed due to an error. So we will index just the base info without the file. $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_ERROR; $this->add_solr_document($filedoc); } /** * Checks to see if a passed file is indexable. * * @param \stored_file $file The file to check * @return bool True if the file can be indexed */ protected function file_is_indexable($file) { if (!empty($this->config->maxindexfilekb) && ($file->get_filesize() > ($this->config->maxindexfilekb * 1024))) { // The file is too big to index. return false; } // Because we now load files into memory to index them in Solr, we also have to ensure that // we don't try to index anything bigger than the memory limit (less 100MB for safety). // Memory limit in cron is MEMORY_EXTRA which is usually 256 or 384MB but can be increased // in config, so this will allow files over 100MB to be indexed. $limit = ini_get('memory_limit'); if ($limit && $limit != -1) { $limitbytes = get_real_size($limit); if ($file->get_filesize() > $limitbytes) { return false; } } $mime = $file->get_mimetype(); if ($mime == 'application/vnd.moodle.backup') { // We don't index Moodle backup files. There is nothing usefully indexable in them. return false; } return true; } /** * Commits all pending changes. * * @return void */ protected function commit() { $this->get_search_client()->commit(); } /** * Do any area cleanup needed, and do anything to confirm contents. * * Return false to prevent the search area completed time and stats from being updated. * * @param \core_search\base $searcharea The search area that was complete * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index is being performed * @return bool True means that data is considered indexed */ public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) { $this->commit(); return true; } /** * Return true if file indexing is supported and enabled. False otherwise. * * @return bool */ public function file_indexing_enabled() { return (bool)$this->config->fileindexing; } /** * Deletes the specified document. * * @param string $id The document id to delete * @return void */ public function delete_by_id($id) { // We need to make sure we delete the item and all related files, which can be done with solr_filegroupingid. $this->get_search_client()->deleteByQuery('solr_filegroupingid:' . $id); $this->commit(); } /** * Delete all area's documents. * * @param string $areaid * @return void */ public function delete($areaid = null) { if ($areaid) { $this->get_search_client()->deleteByQuery('areaid:' . $areaid); } else { $this->get_search_client()->deleteByQuery('*:*'); } $this->commit(); } /** * Pings the Solr server using search_solr config * * @return true|string Returns true if all good or an error string. */ public function is_server_ready() { $configured = $this->is_server_configured(); if ($configured !== true) { return $configured; } // As part of the above we have already checked that we can contact the server. For pages // where performance is important, we skip doing a full schema check as well. if ($this->should_skip_schema_check()) { return true; } // Update schema if required/possible. $schemalatest = $this->check_latest_schema(); if ($schemalatest !== true) { return $schemalatest; } // Check that the schema is already set up. try { $schema = new schema($this); $schema->validate_setup(); } catch (\moodle_exception $e) { return $e->getMessage(); } return true; } /** * Is the solr server properly configured?. * * @return true|string Returns true if all good or an error string. */ public function is_server_configured() { if (empty($this->config->server_hostname) || empty($this->config->indexname)) { return 'No solr configuration found'; } if (!$client = $this->get_search_client(false)) { return get_string('engineserverstatus', 'search'); } try { if ($this->get_solr_major_version() < 4) { // Minimum solr 4.0. return get_string('minimumsolr4', 'search_solr'); } } catch (\SolrClientException $ex) { debugging('Solr client error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER); return get_string('engineserverstatus', 'search'); } catch (\SolrServerException $ex) { debugging('Solr server error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER); return get_string('engineserverstatus', 'search'); } return true; } /** * Returns the solr server major version. * * @return int */ public function get_solr_major_version() { if ($this->solrmajorversion !== null) { return $this->solrmajorversion; } // We should really ping first the server to see if the specified indexname is valid but // we want to minimise solr server requests as they are expensive. system() emits a warning // if it can not connect to the configured index in the configured server. $systemdata = @$this->get_search_client()->system(); $solrversion = $systemdata->getResponse()->offsetGet('lucene')->offsetGet('solr-spec-version'); $this->solrmajorversion = intval(substr($solrversion, 0, strpos($solrversion, '.'))); return $this->solrmajorversion; } /** * Checks if the PHP Solr extension is available. * * @return bool */ public function is_installed() { return function_exists('solr_get_version'); } /** @var int When using the capath option, we generate a bundle containing all the pem files, cached 10 mins. */ const CA_PATH_CACHE_TIME = 600; /** @var int Expired cache files are deleted after this many seconds. */ const CA_PATH_CACHE_DELETE_AFTER = 60; /** * Gets status of Solr server. * * The result has the following fields: * - connected - true if we got a valid JSON response from server * - foundcore - true if we found the core defined in config (this could be false if schema not set up) * * It may have these other fields: * - error - text if anything went wrong * - exception - if an exception was thrown * - indexsize - index size in bytes if we found what it is * * @param int $timeout Optional timeout in seconds, otherwise uses config value * @return array Array with information about status * @since Moodle 5.0 */ public function get_status($timeout = 0): array { $result = ['connected' => false, 'foundcore' => false]; try { $options = []; if ($timeout) { $options['connect_timeout'] = $timeout; $options['read_timeout'] = $timeout; } $before = microtime(true); try { $response = $this->raw_get_request('admin/cores', $options); } finally { $result['time'] = microtime(true) - $before; } $status = $response->getStatusCode(); if ($status !== 200) { $result['error'] = 'Unsuccessful status code: ' . $status; return $result; } $decoded = json_decode($response->getBody()->getContents()); if (!$decoded) { $result['error'] = 'Invalid JSON'; return $result; } // Provided we get some valid JSON then probably Solr exists and is responding. // Any following errors we don't count as not connected (ERROR display in the check) // because maybe it happens if Solr changes their JSON format in a future version. $result['connected'] = true; if (!property_exists($decoded, 'status')) { $result['error'] = 'Unexpected JSON: no core status'; return $result; } foreach ($decoded->status as $core) { $match = false; if (!property_exists($core, 'name')) { $result['error'] = 'Unexpected JSON: core has no name'; return $result; } if ($core->name === $this->config->indexname) { $match = true; } if (!$match && property_exists($core, 'cloud')) { if (!property_exists($core->cloud, 'collection')) { $result['error'] = 'Unexpected JSON: core cloud has no name'; return $result; } if ($core->cloud->collection === $this->config->indexname) { $match = true; } } if ($match) { $result['foundcore'] = true; if (!property_exists($core, 'index')) { $result['error'] = 'Unexpected JSON: core has no index'; return $result; } if (!property_exists($core->index, 'sizeInBytes')) { $result['error'] = 'Unexpected JSON: core index has no sizeInBytes'; return $result; } $result['indexsize'] = $core->index->sizeInBytes; return $result; } } $result['error'] = 'Could not find core matching ' . $this->config->indexname;; return $result; } catch (\Throwable $t) { $result['error'] = 'Exception occurred: ' . $t->getMessage(); $result['exception'] = $t; return $result; } } /** * Returns the solr client instance. * * We don't reuse SolrClient if we are on libcurl 7.35.0, due to a bug in that version of curl. * * @throws \core_search\engine_exception * @param bool $triggerexception * @return \SolrClient */ protected function get_search_client($triggerexception = true) { global $CFG; // Type comparison as it is set to false if not available. if ($this->client !== null) { return $this->client; } $options = array( 'hostname' => $this->config->server_hostname, 'path' => '/solr/' . $this->config->indexname, 'login' => !empty($this->config->server_username) ? $this->config->server_username : '', 'password' => !empty($this->config->server_password) ? $this->config->server_password : '', 'port' => !empty($this->config->server_port) ? $this->config->server_port : '', 'secure' => !empty($this->config->secure) ? true : false, 'ssl_cert' => !empty($this->config->ssl_cert) ? $this->config->ssl_cert : '', 'ssl_key' => !empty($this->config->ssl_key) ? $this->config->ssl_key : '', 'ssl_keypassword' => !empty($this->config->ssl_keypassword) ? $this->config->ssl_keypassword : '', 'ssl_cainfo' => !empty($this->config->ssl_cainfo) ? $this->config->ssl_cainfo : '', 'ssl_capath' => !empty($this->config->ssl_capath) ? $this->config->ssl_capath : '', 'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30' ); if ($CFG->proxyhost && !is_proxybypass('http://' . $this->config->server_hostname . '/')) { $options['proxy_host'] = $CFG->proxyhost; if (!empty($CFG->proxyport)) { $options['proxy_port'] = $CFG->proxyport; } if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) { $options['proxy_login'] = $CFG->proxyuser; $options['proxy_password'] = $CFG->proxypassword; } } if (!class_exists('\SolrClient')) { throw new \core_search\engine_exception('enginenotinstalled', 'search', '', 'solr'); } $client = new \SolrClient($options); if ($client === false && $triggerexception) { throw new \core_search\engine_exception('engineserverstatus', 'search'); } if ($this->cacheclient) { $this->client = $client; } return $client; } /** * Returns a curl object for conntecting to solr. * * @return \curl */ public function get_curl_object() { if (!is_null($this->curl)) { return $this->curl; } // Connection to Solr is allowed to use 'localhost' and other potentially blocked hosts/ports. $this->curl = new \curl(['ignoresecurity' => true]); $options = array(); // Build the SSL options. Based on pecl-solr and general testing. if (!empty($this->config->secure)) { if (!empty($this->config->ssl_cert)) { $options['CURLOPT_SSLCERT'] = $this->config->ssl_cert; $options['CURLOPT_SSLCERTTYPE'] = 'PEM'; } if (!empty($this->config->ssl_key)) { $options['CURLOPT_SSLKEY'] = $this->config->ssl_key; $options['CURLOPT_SSLKEYTYPE'] = 'PEM'; } if (!empty($this->config->ssl_keypassword)) { $options['CURLOPT_KEYPASSWD'] = $this->config->ssl_keypassword; } if (!empty($this->config->ssl_cainfo)) { $options['CURLOPT_CAINFO'] = $this->config->ssl_cainfo; } if (!empty($this->config->ssl_capath)) { $options['CURLOPT_CAPATH'] = $this->config->ssl_capath; } } // Set timeout as for Solr client. $options['CURLOPT_TIMEOUT'] = !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'; $this->curl->setopt($options); if (!empty($this->config->server_username) && !empty($this->config->server_password)) { $authorization = $this->config->server_username . ':' . $this->config->server_password; $this->curl->setHeader('Authorization: Basic ' . base64_encode($authorization)); } return $this->curl; } /** * Return a Moodle url object for the raw server URL (containing all indexes). * * @param string $path The solr path to append. * @return \moodle_url */ public function get_server_url(string $path): \moodle_url { // Must use the proper protocol, or SSL will fail. $protocol = !empty($this->config->secure) ? 'https' : 'http'; $url = $protocol . '://' . rtrim($this->config->server_hostname, '/'); if (!empty($this->config->server_port)) { $url .= ':' . $this->config->server_port; } $url .= '/solr/' . ltrim($path, '/'); return new \moodle_url($url); } /** * Return a Moodle url object for the server connection including the search index. * * @param string $path The solr path to append. * @return \moodle_url */ public function get_connection_url($path) { return $this->get_server_url($this->config->indexname . '/' . ltrim($path, '/')); } /** * Calls the Solr engine with a GET request (for things the Solr extension doesn't support). * * This has similar result to get_curl_object but uses the newer (mockable) Guzzle HTTP client. * * @param string $path URL path (after /solr/) e.g. 'admin/cores?action=STATUS&core=frog' * @param array $overrideoptions Optional array of Guzzle options, will override config * @return \Psr\Http\Message\ResponseInterface Response message from Guzzle * @throws \GuzzleHttp\Exception\GuzzleException If any problem connecting * @since Moodle 5.0 */ public function raw_get_request( string $path, array $overrideoptions = [], ): \Psr\Http\Message\ResponseInterface { $client = \core\di::get(\core\http_client::class); return $client->get( $this->get_server_url($path)->out(false), $this->get_http_client_options($overrideoptions), ); } /** * Gets the \core\http_client options for a connection. * * @param array $overrideoptions Optional array to override some of the options * @return array Array of http_client options */ protected function get_http_client_options(array $overrideoptions = []): array { $options = [ 'connect_timeout' => !empty($this->config->server_timeout) ? (int)$this->config->server_timeout : 30, ]; $options['read_timeout'] = $options['connect_timeout']; if (!empty($this->config->server_username)) { $options['auth'] = [$this->config->server_username, $this->config->server_password]; } if (!empty($this->config->ssl_cert)) { $options['cert'] = $this->config->ssl_cert; } if (!empty($this->config->ssl_key)) { if (!empty($this->config->ssl_keypassword)) { $options['ssl_key'] = [$this->config->ssl_key, $this->config->ssl_keypassword]; } else { $options['ssl_key'] = $this->config->ssl_key; } } if (!empty($this->config->ssl_cainfo)) { $options['verify'] = $this->config->ssl_cainfo; } else if (!empty($this->config->ssl_capath)) { // Guzzle doesn't support a whole path of CA certs, so we have to make a single file // with all the *.pem files in that directory. It needs to be in filesystem so we can // use it directly, let's put it in local cache for 10 minutes. $cachefolder = make_localcache_directory('search_solr'); $prefix = 'capath.' . sha1($this->config->ssl_capath); $now = \core\di::get(\core\clock::class)->time(); $got = false; foreach (scandir($cachefolder) as $filename) { // You are not allowed to overwrite files in localcache folders so we use files // with the time in, and delete old files with a 1 minute delay to avoid race // conditions. if (preg_match('~^(.*)\.([0-9]+)$~', $filename, $matches)) { [1 => $fileprefix, 2 => $time] = $matches; $pathname = $cachefolder . '/' . $filename; if ($time > $now - self::CA_PATH_CACHE_TIME && $fileprefix === $prefix) { $options['verify'] = $pathname; $got = true; break; } else if ($time <= $now - self::CA_PATH_CACHE_TIME - self::CA_PATH_CACHE_DELETE_AFTER) { unlink($pathname); } } } if (!$got) { // If we don't have it yet, we need to make the cached file. $allpems = ''; foreach (scandir($this->config->ssl_capath) as $filename) { if (preg_match('~\.pem$~', $filename)) { $pathname = $this->config->ssl_capath . '/' . $filename; $allpems .= file_get_contents($pathname) . "\n\n"; } } $pathname = $cachefolder . '/' . $prefix . '.' . $now; file_put_contents($pathname, $allpems); $options['verify'] = $pathname; } } // Apply other/overridden options. foreach ($overrideoptions as $name => $value) { $options[$name] = $value; } return $options; } /** * Solr includes group support in the execute_query function. * * @return bool True */ public function supports_group_filtering() { return true; } protected function update_schema($oldversion, $newversion) { // Construct schema. $schema = new schema($this); $cansetup = $schema->can_setup_server(); if ($cansetup !== true) { return $cansetup; } switch ($newversion) { // This version just requires a setup call to add new fields. case 2017091700: $setup = true; break; // If we don't know about the schema version we might not have implemented the // change correctly, so return. default: return get_string('schemaversionunknown', 'search'); } if ($setup) { $schema->setup(); } return true; } /** * Solr supports sort by location within course contexts or below. * * @param \context $context Context that the user requested search from * @return array Array from order name => display text */ public function get_supported_orders(\context $context) { $orders = parent::get_supported_orders($context); // If not within a course, no other kind of sorting supported. $coursecontext = $context->get_course_context(false); if ($coursecontext) { // Within a course or activity/block, support sort by location. $orders['location'] = get_string('order_location', 'search', $context->get_context_name()); } return $orders; } /** * Solr supports search by user id. * * @return bool True */ public function supports_users() { return true; } /** * Solr supports adding documents in a batch. * * @return bool True */ public function supports_add_document_batch(): bool { return true; } /** * Solr supports deleting the index for a context. * * @param int $oldcontextid Context that has been deleted * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_context(int $oldcontextid) { $client = $this->get_search_client(); try { $client->deleteByQuery('contextid:' . $oldcontextid); $client->commit(true); return true; } catch (\Exception $e) { throw new \core_search\engine_exception('error_solr', 'search_solr', '', $e->getMessage()); } } /** * Solr supports deleting the index for a course. * * @param int $oldcourseid * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_course(int $oldcourseid) { $client = $this->get_search_client(); try { $client->deleteByQuery('courseid:' . $oldcourseid); $client->commit(true); return true; } catch (\Exception $e) { throw new \core_search\engine_exception('error_solr', 'search_solr', '', $e->getMessage()); } } /** * Checks if an alternate configuration has been defined. * * @return bool True if alternate configuration is available */ public function has_alternate_configuration(): bool { return !empty($this->config->alternateserver_hostname) && !empty($this->config->alternateindexname) && !empty($this->config->alternateserver_port); } } engine/solr/classes/check/connection.php000064400000011437152265255700014371 0ustar00. namespace search_solr\check; use core\check\check; use core\check\result; use core\output\html_writer; /** * Check that the connection to Solr works. * * @package search_solr * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class connection extends check { #[\Override] public function get_name(): string { return get_string('pluginname', 'search_solr'); } #[\Override] public function get_action_link(): ?\action_link { return new \action_link( new \moodle_url('/admin/settings.php', ['section' => 'searchsolr']), get_string('settings')); } #[\Override] public function get_result(): result { global $CFG; $result = result::OK; $resultstr = ''; $resultdetails = ''; try { // We do not use manager::instance as this will already try to connect to the engine, // we only want to do the specific get_status call below and nothing else. So use // search_engine_instance. We know it will be a Solr instance if we got here. /** @var \search_solr\engine $engine */ $engine = \core_search\manager::search_engine_instance(); // Get engine status. $status = $engine->get_status(5); $time = number_format($status['time'], 2) . 's'; $resultstr = get_string('check_time', 'search_solr', $time); } catch (\Throwable $t) { $status = [ 'connected' => false, 'foundcore' => false, 'error' => 'Exception when creating search manager: ' . $t->getMessage(), 'exception' => $t, ]; } if (!$status['connected']) { // No connection at all. $result = result::ERROR; $resultstr = get_string('check_notconnected', 'search_solr'); $resultdetails .= \html_writer::tag('p', s($status['error'])); } else if (!$status['foundcore']) { // There's a connection, but the core doesn't seem to exist. $result = result::ERROR; $resultstr = get_string('check_nocore', 'search_solr'); $resultdetails .= \html_writer::tag('p', s($status['error'])); } else { // Errors related to finding the core size only show if the size warning is configured. $sizelimit = get_config('search_solr', 'indexsizelimit'); if (!array_key_exists('indexsize', $status)) { if ($sizelimit) { $result = result::ERROR; $resultstr = get_string('check_nosize', 'search_solr'); $resultdetails .= \html_writer::tag('p', s($status['error'])); } } else { // Show the index size in result, even if we aren't checking it. $sizestr = get_string( 'indexsize', 'search_solr', display_size($status['indexsize']), ); $resultdetails .= \html_writer::tag('p', $sizestr); if ($sizelimit) { // Error at specified index size, warning at 90% of it. $sizewarning = ($sizelimit * 9) / 10; if ($status['indexsize'] > $sizewarning) { if ($status['indexsize'] > $sizelimit) { $resultstr = get_string('check_indextoobig', 'search_solr'); $result = result::ERROR; } else { // We don't say it's too big because it isn't yet, just show the size. $resultstr = $sizestr; $result = result::WARNING; } } } } } $ex = $status['exception'] ?? null; if ($ex) { $resultdetails .= \html_writer::tag('pre', str_replace($CFG->dirroot, '', s($ex->getTraceAsString()))); } return new result($result, $resultstr, $resultdetails); } } engine/solr/classes/schema.php000064400000027451152265255700012420 0ustar00. /** * Solr schema manipulation manager. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/filelib.php'); /** * Schema class to interact with Solr schema. * * At the moment it only implements create which should be enough for a basic * moodle configuration in Solr. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class schema { /** * @var stdClass */ protected $config = null; /** * cUrl instance. * @var \curl */ protected $curl = null; /** * An engine instance. * @var engine */ protected $engine = null; /** * Constructor. * * @param engine $engine Optional engine parameter, if not specified then one will be created * @throws \moodle_exception * @return void */ public function __construct(?engine $engine = null) { if (!$this->config = get_config('search_solr')) { throw new \moodle_exception('missingconfig', 'search_solr'); } if (empty($this->config->server_hostname) || empty($this->config->indexname)) { throw new \moodle_exception('missingconfig', 'search_solr'); } $this->engine = $engine ?? new engine(); $this->curl = $this->engine->get_curl_object(); // HTTP headers. $this->curl->setHeader('Content-type: application/json'); } /** * Can setup be executed against the configured server. * * @return true|string True or error message. */ public function can_setup_server() { $status = $this->engine->is_server_configured(); if ($status !== true) { return $status; } // At this stage we know that the server is properly configured with a valid host:port and indexname. // We're not too concerned about repeating the SolrClient::system() call (already called in // is_server_configured) because this is just a setup script. if ($this->engine->get_solr_major_version() < 5) { // Schema setup script only available for 5.0 onwards. return get_string('schemasetupfromsolr5', 'search_solr'); } return true; } /** * Setup solr stuff required by moodle. * * @param bool $checkexisting Whether to check if the fields already exist or not * @return bool */ public function setup($checkexisting = true) { $fields = \search_solr\document::get_default_fields_definition(); // Field id is already there. unset($fields['id']); $this->check_index(); $return = $this->add_fields($fields, $checkexisting); // Tell the engine we are now using the latest schema version. $this->engine->record_applied_schema_version(document::SCHEMA_VERSION); return $return; } /** * Checks the schema is properly set up. * * @throws \moodle_exception * @return void */ public function validate_setup() { $fields = \search_solr\document::get_default_fields_definition(); // Field id is already there. unset($fields['id']); $this->check_index(); $this->validate_fields($fields, true); } /** * Checks if the index is ready, triggers an exception otherwise. * * @throws \moodle_exception * @return void */ protected function check_index() { // Check that the server is available and the index exists. $url = $this->engine->get_connection_url('/select?wt=json'); $result = $this->curl->get($url); if ($this->curl->error) { throw new \moodle_exception('connectionerror', 'search_solr'); } if ($this->curl->info['http_code'] === 404) { throw new \moodle_exception('connectionerror', 'search_solr'); } } /** * Adds the provided fields to Solr schema. * * Intentionally separated from create(), it can be called to add extra fields. * fields separately. * * @throws \coding_exception * @throws \moodle_exception * @param array $fields \core_search\document::$requiredfields format * @param bool $checkexisting Whether to check if the fields already exist or not * @return bool */ protected function add_fields($fields, $checkexisting = true) { if ($checkexisting) { // Check that non of them exists. $this->validate_fields($fields, false); } $url = $this->engine->get_connection_url('/schema'); // Add all fields. foreach ($fields as $fieldname => $data) { if (!isset($data['type']) || !isset($data['stored']) || !isset($data['indexed'])) { throw new \coding_exception($fieldname . ' does not define all required field params: type, stored and indexed.'); } $type = $this->doc_field_to_solr_field($data['type']); // Changing default multiValued value to false as we want to match values easily. $params = array( 'add-field' => array( 'name' => $fieldname, 'type' => $type, 'stored' => $data['stored'], 'multiValued' => false, 'indexed' => $data['indexed'] ) ); $results = $this->curl->post($url, json_encode($params)); // We only validate if we are interested on it. if ($checkexisting) { if ($this->curl->error) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $this->curl->error); } $this->validate_add_field_result($results); } } return true; } /** * Checks if the schema existing fields are properly set, triggers an exception otherwise. * * @throws \moodle_exception * @param array $fields * @param bool $requireexisting Require the fields to exist, otherwise exception. * @return void */ protected function validate_fields(&$fields, $requireexisting = false) { global $CFG; foreach ($fields as $fieldname => $data) { $url = $this->engine->get_connection_url('/schema/fields/' . $fieldname); $results = $this->curl->get($url); if ($this->curl->error) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $this->curl->error); } if (!$results) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('nodatafromserver', 'search_solr')); } $results = json_decode($results); if ($requireexisting && !empty($results->error) && $results->error->code === 404) { $a = new \stdClass(); $a->fieldname = $fieldname; $a->setupurl = $CFG->wwwroot . '/search/engine/solr/setup_schema.php'; throw new \moodle_exception('errorvalidatingschema', 'search_solr', '', $a); } // The field should not exist so we only accept 404 errors. if (empty($results->error) || (!empty($results->error) && $results->error->code !== 404)) { if (!empty($results->error)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $results->error->msg); } else { // All these field attributes are set when fields are added through this script and should // be returned and match the defined field's values. $expectedsolrfield = $this->doc_field_to_solr_field($data['type']); if (empty($results->field) || !isset($results->field->type) || !isset($results->field->multiValued) || !isset($results->field->indexed) || !isset($results->field->stored)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('schemafieldautocreated', 'search_solr', $fieldname)); } else if ($results->field->type !== $expectedsolrfield || $results->field->multiValued !== false || $results->field->indexed !== $data['indexed'] || $results->field->stored !== $data['stored']) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('schemafieldautocreated', 'search_solr', $fieldname)); } else { // The field already exists and it is properly defined, no need to create it. unset($fields[$fieldname]); } } } } } /** * Checks that the field results do not contain errors. * * @throws \moodle_exception * @param string $results curl response body * @return void */ protected function validate_add_field_result($result) { if (!$result) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('nodatafromserver', 'search_solr')); } $results = json_decode($result); if (!$results) { if (is_scalar($result)) { $errormsg = $result; } else { $errormsg = json_encode($result); } throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $errormsg); } // It comes as error when fetching fields data. if (!empty($results->error)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $results->error); } // It comes as errors when adding fields. if (!empty($results->errors)) { // We treat this error separately. $errorstr = ''; foreach ($results->errors as $error) { $errorstr .= implode(', ', $error->errorMessages); } throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $errorstr); } } /** * Returns the solr field type from the document field type string. * * @param string $datatype * @return string */ private function doc_field_to_solr_field($datatype) { $type = $datatype; $solrversion = $this->engine->get_solr_major_version(); switch($datatype) { case 'text': $type = 'text_general'; break; case 'int': if ($solrversion >= 7) { $type = 'pint'; } break; case 'tdate': if ($solrversion >= 7) { $type = 'pdate'; } break; } return $type; } } engine/solr/classes/document.php000064400000015264152265255700012775 0ustar00. /** * Document representation. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); /** * Respresents a document to index. * * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document extends \core_search\document { /** * Indicates the file contents were not indexed due to an error. */ const INDEXED_FILE_ERROR = -1; /** * Indicates the file contents were not indexed due filtering/settings. */ const INDEXED_FILE_FALSE = 0; /** * Indicates the file contents are indexed with the record. */ const INDEXED_FILE_TRUE = 1; /** * Any fields that are engine specifc. These are fields that are solely used by a seach engine plugin * for internal purposes. * * @var array */ protected static $enginefields = array( 'solr_filegroupingid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'solr_fileid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'solr_filecontenthash' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), // Stores the status of file indexing. 'solr_fileindexstatus' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), // Field to index, but not store, file contents. 'solr_filecontent' => array( 'type' => 'text', 'stored' => false, 'indexed' => true, 'mainquery' => true ) ); /** * Formats the timestamp according to the search engine needs. * * @param int $timestamp * @return string */ public static function format_time_for_engine($timestamp) { return gmdate(\search_solr\engine::DATE_FORMAT, $timestamp); } /** * Formats the timestamp according to the search engine needs. * * @param int $timestamp * @return string */ public static function format_string_for_engine($string) { // 2^15 default. We could convert this to a setting as is possible to // change the max in solr. return \core_text::str_max_bytes($string, 32766); } /** * Returns a timestamp from the value stored in the search engine. * * @param string $time * @return int */ public static function import_time_from_engine($time) { return strtotime($time); } /** * Overwritten to use HTML (highlighting). * * @return int */ protected function get_text_format() { return FORMAT_HTML; } /** * Formats a text string coming from the search engine. * * Even if this is called through an external function it is fine to return HTML as * HTML is considered solr's search engine text format. An external function can ask * for raw text, but this just means that it will not pass through format_text, no that * we can not add HTML. * * @param string $text Text to format * @return string HTML text to be renderer */ protected function format_text($text) { // Since we allow output for highlighting, we need to encode html entities. // This ensures plaintext html chars don't become valid html. $out = s($text); $startcount = 0; $endcount = 0; // Remove end/start pairs that span a few common seperation characters. Allows us to highlight phrases instead of words. $regex = '|'.engine::HIGHLIGHT_END.'([ .,-]{0,3})'.engine::HIGHLIGHT_START.'|'; $out = preg_replace($regex, '$1', $out); // Now replace our start and end highlight markers. $out = str_replace(engine::HIGHLIGHT_START, '', $out, $startcount); $out = str_replace(engine::HIGHLIGHT_END, '', $out, $endcount); // This makes sure any highlight tags are balanced, incase truncation or the highlight text contained our markers. while ($startcount > $endcount) { $out .= ''; $endcount++; } while ($startcount < $endcount) { $out = '' . $out; $endcount++; } return parent::format_text($out); } /** * Apply any defaults to unset fields before export. Called after document building, but before export. * * Sub-classes of this should make sure to call parent::apply_defaults(). */ protected function apply_defaults() { parent::apply_defaults(); // We want to set the solr_filegroupingid to id if it isn't set. if (!isset($this->data['solr_filegroupingid'])) { $this->data['solr_filegroupingid'] = $this->data['id']; } } /** * Export the data for the given file in relation to this document. * * @param \stored_file $file The stored file we are talking about. * @return array */ public function export_file_for_engine($file) { $data = $this->export_for_engine(); // Content is index in the main document. unset($data['content']); unset($data['description1']); unset($data['description2']); // Going to append the fileid to give it a unique id. $data['id'] = $data['id'].'-solrfile'.$file->get_id(); $data['type'] = \core_search\manager::TYPE_FILE; $data['solr_fileid'] = $file->get_id(); $data['solr_filecontenthash'] = $file->get_contenthash(); $data['solr_fileindexstatus'] = self::INDEXED_FILE_TRUE; $data['title'] = $file->get_filename(); $data['modified'] = self::format_time_for_engine($file->get_timemodified()); return $data; } } engine/solr/setup_schema.php000064400000004004152265255700012170 0ustar00. /** * Adds moodle fields to solr schema. * * Schema REST API write actions are only available from Solr 4.4 onwards. * * The schema should be managed and mutable to allow this script * to add new fields to the schema. * * @link https://cwiki.apache.org/confluence/display/solr/Managed+Schema+Definition+in+SolrConfig * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir.'/adminlib.php'); require_login(null, false); require_capability('moodle/site:config', context_system::instance()); $returnurl = new moodle_url('/admin/settings.php', array('section' => 'manageglobalsearch')); $schema = new \search_solr\schema(); $status = $schema->can_setup_server(); if ($status !== true) { $PAGE->set_context(context_system::instance()); $PAGE->set_url(new moodle_url('/search/engine/solr/setup_schema.php')); echo $OUTPUT->header(); echo $OUTPUT->notification($status, \core\output\notification::NOTIFY_ERROR); echo $OUTPUT->box($OUTPUT->action_link($returnurl, get_string('continue')), 'generalbox centerpara'); echo $OUTPUT->footer(); exit(1); } $schema->setup(); redirect($returnurl, get_string('setupok', 'search_solr'), 4); engine/solr/tests/fixtures/testable_engine.php000064400000003020152265255700015650 0ustar00. namespace search_solr; /** * Search engine for testing purposes. * * @package search_solr * @category phpunit * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; class testable_engine extends \search_solr\engine { /** * Function that lets us update the internally cached config object of the engine. */ public function test_set_config($name, $value) { $this->config->$name = $value; } /** * Gets the search client (this function is usually protected) for testing. * * @return \SolrClient Solr client object * @throws \core_search\engine_exception */ public function get_search_client_public(): \SolrClient { return parent::get_search_client(); } } engine/solr/tests/privacy/provider_test.php000064400000003336152265255700015227 0ustar00. /** * Privacy provider tests. * * @package search_solr * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy provider tests class. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { /** * Verify that a collection of metadata is returned for this component and that it just links to an external location. */ public function test_get_metadata(): void { $collection = new \core_privacy\local\metadata\collection('search_solr'); $collection = \search_solr\privacy\provider::get_metadata($collection); $this->assertNotEmpty($collection); $items = $collection->get_collection(); $this->assertEquals(1, count($items)); $this->assertInstanceOf(\core_privacy\local\metadata\types\external_location::class, $items[0]); } } engine/solr/tests/engine_test.php000064400000175100152265255700013164 0ustar00. namespace search_solr; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); require_once($CFG->dirroot . '/search/engine/solr/tests/fixtures/testable_engine.php'); /** * Solr search engine base unit tests. * * Required params: * - define('TEST_SEARCH_SOLR_HOSTNAME', '127.0.0.1'); * - define('TEST_SEARCH_SOLR_PORT', '8983'); * - define('TEST_SEARCH_SOLR_INDEXNAME', 'unittest'); * * Optional params: * - define('TEST_SEARCH_SOLR_USERNAME', ''); * - define('TEST_SEARCH_SOLR_PASSWORD', ''); * - define('TEST_SEARCH_SOLR_SSLCERT', ''); * - define('TEST_SEARCH_SOLR_SSLKEY', ''); * - define('TEST_SEARCH_SOLR_KEYPASSWORD', ''); * - define('TEST_SEARCH_SOLR_CAINFOCERT', ''); * * @package search_solr * @category test * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @runTestsInSeparateProcesses */ final class engine_test extends \advanced_testcase { /** * @var \core_search\manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); set_config('enableglobalsearch', true); set_config('searchengine', 'solr'); if (!function_exists('solr_get_version')) { $this->markTestSkipped('Solr extension is not loaded.'); } if (!defined('TEST_SEARCH_SOLR_HOSTNAME') || !defined('TEST_SEARCH_SOLR_INDEXNAME') || !defined('TEST_SEARCH_SOLR_PORT')) { $this->markTestSkipped('Solr extension test server not set.'); } set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr'); set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr'); set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr'); if (defined('TEST_SEARCH_SOLR_USERNAME')) { set_config('server_username', TEST_SEARCH_SOLR_USERNAME, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_PASSWORD')) { set_config('server_password', TEST_SEARCH_SOLR_PASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLCERT')) { set_config('secure', true, 'search_solr'); set_config('ssl_cert', TEST_SEARCH_SOLR_SSLCERT, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLKEY')) { set_config('ssl_key', TEST_SEARCH_SOLR_SSLKEY, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_KEYPASSWORD')) { set_config('ssl_keypassword', TEST_SEARCH_SOLR_KEYPASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_CAINFOCERT')) { set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr'); } set_config('fileindexing', 1, 'search_solr'); // We are only test indexing small string files, so setting this as low as we can. set_config('maxindexfilekb', 1, 'search_solr'); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); // Inject search solr engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_solr\testable_engine(); $this->search = \testable_core_search::instance($this->engine); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); $this->setAdminUser(); // Cleanup before doing anything on it as the index it is out of this test control. $this->search->delete_index(); // Add moodle fields if they don't exist. $schema = new \search_solr\schema($this->engine); $schema->setup(false); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } parent::tearDown(); } /** * Simple data provider to allow tests to be run with file indexing on and off. */ public static function file_indexing_provider(): array { return array( 'file-indexing-on' => array(1), 'file-indexing-off' => array(0) ); } public function test_connection(): void { $this->assertTrue($this->engine->is_server_ready()); } /** * Tests that the alternate settings are used when configured. */ public function test_alternate_settings(): void { // Index a couple of things. $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); // By default settings, alternates are not set. $this->assertFalse($this->engine->has_alternate_configuration()); // Set up all the config the same as normal. foreach (['server_hostname', 'indexname', 'secure', 'server_port', 'server_username', 'server_password'] as $setting) { set_config('alternate' . $setting, get_config('search_solr', $setting), 'search_solr'); } // Also mess up the normal config. set_config('indexname', 'not_the_right_index_name', 'search_solr'); // Construct a new engine using normal settings. $engine = new engine(); // Now alternates are available. $this->assertTrue($engine->has_alternate_configuration()); // But it won't actually work because of the bogus index name. $this->assertFalse($engine->is_server_ready() === true); $this->assertDebuggingCalled(); // But if we construct one using alternate settings, it will work as normal. $engine = new engine(true); $this->assertTrue($engine->is_server_ready()); // Including finding the search results. $this->assertCount(2, $engine->execute_query( (object)['q' => 'message'], (object)['everything' => true])); } /** * @dataProvider file_indexing_provider */ public function test_index($fileindexing): void { global $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $record = new \stdClass(); $record->timemodified = time() - 1; $this->generator->create_record($record); // Data gets into the search engine. $this->assertTrue($this->search->index()); // Not anymore as everything was already added. sleep(1); $this->assertFalse($this->search->index()); $this->generator->create_record(); // Indexing again once there is new data. $this->assertTrue($this->search->index()); } /** * Better keep this not very strict about which or how many results are returned as may depend on solr engine config. * * @dataProvider file_indexing_provider * * @return void */ public function test_search($fileindexing): void { global $USER, $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $record = new \stdClass(); $record->title = "Special title"; $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); // Based on core_mocksearch\search\indexer. $this->assertEquals($USER->id, $results[0]->get('userid')); $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid')); // Do a test to make sure we aren't searching non-query fields, like areaid. $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->assertCount(0, $this->search->search($querydata)); $querydata->q = 'message'; sleep(1); $beforeadding = time(); sleep(1); $this->generator->create_record(); $this->search->index(); // Timestart. $querydata->timestart = $beforeadding; $this->assertCount(1, $this->search->search($querydata)); // Timeend. unset($querydata->timestart); $querydata->timeend = $beforeadding; $this->assertCount(2, $this->search->search($querydata)); // Title. unset($querydata->timeend); $querydata->title = 'Special title'; $this->assertCount(1, $this->search->search($querydata)); // Course IDs. unset($querydata->title); $querydata->courseids = array(SITEID + 1); $this->assertCount(0, $this->search->search($querydata)); $querydata->courseids = array(SITEID); $this->assertCount(3, $this->search->search($querydata)); // Now try some area-id combinations. unset($querydata->courseids); $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $querydata->areaids = array($forumpostareaid); $this->assertCount(0, $this->search->search($querydata)); $querydata->areaids = array($forumpostareaid, $mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array($mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array(); $this->assertCount(3, $this->search->search($querydata)); // Check that index contents get updated. $this->generator->delete_all(); $this->search->index(true); unset($querydata->title); $querydata->q = '*'; $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_delete($fileindexing): void { $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $this->assertCount(2, $this->search->search($querydata)); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->delete_index($areaid); $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_alloweduserid($fileindexing): void { $this->engine->test_set_config('fileindexing', $fileindexing); $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); // Get the doc and insert the default doc. $doc = $area->get_document($record); $this->engine->add_document($doc); $users = array(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); // Add a record that only user 100 can see. $originalid = $doc->get('id'); // Now add a custom doc for each user. foreach ($users as $user) { $doc = $area->get_document($record); $doc->set('id', $originalid.'-'.$user->id); $doc->set('owneruserid', $user->id); $this->engine->add_document($doc); } $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'message'; $querydata->title = $doc->get('title'); // We are going to go through each user and see if they get the original and the owned doc. foreach ($users as $user) { $this->setUser($user); $results = $this->search->search($querydata); $this->assertCount(2, $results); $owned = 0; $notowned = 0; // We don't know what order we will get the results in, so we are doing this. foreach ($results as $result) { $owneruserid = $result->get('owneruserid'); if (empty($owneruserid)) { $notowned++; $this->assertEquals(0, $owneruserid); $this->assertEquals($originalid, $result->get('id')); } else { $owned++; $this->assertEquals($user->id, $owneruserid); $this->assertEquals($originalid.'-'.$user->id, $result->get('id')); } } $this->assertEquals(1, $owned); $this->assertEquals(1, $notowned); } // Now test a user with no owned results. $otheruser = $this->getDataGenerator()->create_user(); $this->setUser($otheruser); $results = $this->search->search($querydata); $this->assertCount(1, $results); $this->assertEquals(0, $results[0]->get('owneruserid')); $this->assertEquals($originalid, $results[0]->get('id')); } /** * @dataProvider file_indexing_provider */ public function test_highlight($fileindexing): void { global $PAGE; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|'; $this->assertMatchesRegularExpression($regex, $result->get('content')); $searchrenderer = $PAGE->get_renderer('core_search'); $exported = $result->export_for_template($searchrenderer); $regex = '|message|'; $this->assertMatchesRegularExpression($regex, $exported['content']); } public function test_export_file_for_engine(): void { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); $filerecord = new \stdClass(); $filerecord->timemodified = 978310800; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filearray = $doc->export_file_for_engine($file); $this->assertEquals(\core_search\manager::TYPE_FILE, $filearray['type']); $this->assertEquals($file->get_id(), $filearray['solr_fileid']); $this->assertEquals($file->get_contenthash(), $filearray['solr_filecontenthash']); $this->assertEquals(\search_solr\document::INDEXED_FILE_TRUE, $filearray['solr_fileindexstatus']); $this->assertEquals($file->get_filename(), $filearray['title']); $this->assertEquals(978310800, \search_solr\document::import_time_from_engine($filearray['modified'])); } public function test_index_file(): void { // Very simple test. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); $querydata->q = '"File contents"'; $this->assertCount(1, $this->search->search($querydata)); } public function test_reindexing_files(): void { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); $files = array(); $filerecord = new \stdClass(); // We make enough so that we pass the 500 files threashold. That is the boundary when getting files. $boundary = 500; $top = (int)($boundary * 1.1); for ($i = 0; $i < $top; $i++) { $filerecord->filename = 'searchfile'.$i; $filerecord->content = 'Some FileContents'.$i; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $files[] = $file; } // Add the doc with lots of files, then commit. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Indexes we are going to check. 0 means we will delete, 1 means we will keep. $checkfiles = array( 0 => 0, // Check the begining of the set. 1 => 1, 2 => 0, ($top - 3) => 0, // Check the end of the set. ($top - 2) => 1, ($top - 1) => 0, ($boundary - 2) => 0, // Check at the boundary between fetch groups. ($boundary - 1) => 0, $boundary => 0, ($boundary + 1) => 0, ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones. ((int)($boundary * 1.05)) => 1 ); $querydata = new \stdClass(); // First, check that all the files are currently there. foreach ($checkfiles as $key => $unused) { $querydata->q = 'FileContents'.$key; $this->assertCount(1, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount(1, $this->search->search($querydata)); } // Remove the files we want removed from the files array. foreach ($checkfiles as $key => $keep) { if (!$keep) { unset($files[$key]); } } // And make us a new file to add. $filerecord->filename = 'searchfileNew'; $filerecord->content = 'Some FileContentsNew'; $files[] = $this->generator->create_file($filerecord); $checkfiles['New'] = 1; $doc = $area->get_document($record); foreach($files as $file) { $doc->add_stored_file($file); } // Reindex the document with the changed files. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Go through our check array, and see if the file is there or not. foreach ($checkfiles as $key => $keep) { $querydata->q = 'FileContents'.$key; $this->assertCount($keep, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount($keep, $this->search->search($querydata)); } // Now check that we get one result when we search from something in all of them. $querydata->q = 'Some'; $this->assertCount(1, $this->search->search($querydata)); } /** * Test indexing a file we don't consider indexable. */ public function test_index_filtered_file(): void { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); // Get a single record to make a doc from. $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); // We need to make a file greater than 1kB in size, which is the lowest filter size. $filerecord = new \stdClass(); $filerecord->filename = 'largefile'; $filerecord->content = 'Some LargeFindContent to find.'; for ($i = 0; $i < 200; $i++) { $filerecord->content .= ' The quick brown fox jumps over the lazy dog.'; } $this->assertGreaterThan(1024, strlen($filerecord->content)); $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filerecord->filename = 'smallfile'; $filerecord->content = 'Some SmallFindContent to find.'; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); // We shouldn't be able to find the large file contents. $querydata->q = 'LargeFindContent'; $this->assertCount(0, $this->search->search($querydata)); // But we should be able to find the filename. $querydata->q = 'largefile'; $this->assertCount(1, $this->search->search($querydata)); // We should be able to find the small file contents. $querydata->q = 'SmallFindContent'; $this->assertCount(1, $this->search->search($querydata)); // And we should be able to find the filename. $querydata->q = 'smallfile'; $this->assertCount(1, $this->search->search($querydata)); } public function test_delete_by_id(): void { // First get files in the index. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); // Then search to make sure they are there. $querydata->q = '"File contents"'; $results = $this->search->search($querydata); $this->assertCount(2, $results); $first = reset($results); $deleteid = $first->get('id'); $this->engine->delete_by_id($deleteid); // Check that we don't get a result for it anymore. $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $this->assertNotEquals($deleteid, $result->get('id')); } /** * Test that expected results are returned, even with low check_access success rate. * * @dataProvider file_indexing_provider */ public function test_solr_filling($fileindexing): void { $this->engine->test_set_config('fileindexing', $fileindexing); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); // We are going to create a bunch of records that user 1 can see with 2 keywords. // Then we are going to create a bunch for user 2 with only 1 of the keywords. // If user 2 searches for both keywords, solr will return all of the user 1 results, then the user 2 results. // This is because the user 1 results will match 2 keywords, while the others will match only 1. $record = new \stdClass(); // First create a bunch of records for user 1 to see. $record->denyuserids = array($user2->id); $record->content = 'Something1 Something2'; $maxresults = (int)(\core_search\manager::MAX_RESULTS * .75); for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } // Then create a bunch of records for user 2 to see. $record->denyuserids = array($user1->id); $record->content = 'Something1'; for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user1); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2'; $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); // Check that user 2 will see theirs, even though they may be crouded out. $this->setUser($user2); $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); } /** * Create 40 docs, that will be return from Solr in 10 hidden, 10 visible, 10 hidden, 10 visible if you query for: * Something1 Something2 Something3 Something4, with the specified user set. */ protected function setup_user_hidden_docs($user) { // These results will come first, and will not be visible by the user. $record = new \stdClass(); $record->denyuserids = array($user->id); $record->content = 'Something1 Something2 Something3 Something4'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come second, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 Something2 Something3'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come third, and will not be visible by the user. $record->denyuserids = array($user->id); $record->content = 'Something1 Something2'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come fourth, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 '; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } } /** * Test that counts are what we expect. * * @dataProvider file_indexing_provider */ public function test_get_query_total_count($fileindexing): void { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); $this->setUser($user); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // In this first set, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 5); $this->assertEquals(30, $this->engine->get_query_total_count()); $this->assertCount(5, $results); // To get to 15, it has to process the first 10 that are bad, 10 that are good, 10 that are bad, then 5 that are good. // So we now know 20 are bad out of 40. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 15); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(15, $results); // Try to get more then all, make sure we still see 20 count and 20 returned. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 30); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(20, $results); } /** * Test that paged results are what we expect. * * @dataProvider file_indexing_provider */ public function test_manager_paged_search($fileindexing): void { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // On this first page, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->search->paged_search($querydata, 0); $this->assertEquals(30, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(0, $results->actualpage); // On the second page, it should have found the next 10 bad ones, so we no know there are only 20 total. $results = $this->search->paged_search($querydata, 1); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); // Try to get an additional page - we should get back page 1 results, since that is the last page with valid results. $results = $this->search->paged_search($querydata, 2); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); } /** * Tests searching for results restricted to context id. */ public function test_context_restriction(): void { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and some forums. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1', 'summary' => 'xyzzy']); $contextc1 = \context_course::instance($course1->id); $course1forum1 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F1', 'intro' => 'xyzzy']); $contextc1f1 = \context_module::instance($course1forum1->cmid); $course1forum2 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F2', 'intro' => 'xyzzy']); $contextc1f2 = \context_module::instance($course1forum2->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2', 'summary' => 'xyzzy']); $contextc2 = \context_course::instance($course1->id); $course2forum = $generator->create_module('forum', ['course' => $course2, 'name' => 'C2F', 'intro' => 'xyzzy']); $contextc2f = \context_module::instance($course2forum->cmid); // Index the courses and forums. $this->search->index(); // Search as admin user should find everything. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Course 1', 'Course 2', 'C1F1', 'C1F2', 'C2F'], $results); // Admin user manually restricts results by context id to include one course and one forum. $querydata->contextids = [$contextc2f->id, $contextc1->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Student enrolled in only one course, same restriction, only has the available results. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course2->id, 'student'); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); // Student enrolled in both courses, same restriction, same results as admin. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Restrict both course and context. $querydata->courseids = [$course2->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); unset($querydata->courseids); // Restrict both area and context. $querydata->areaids = ['core_course-course']; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1'], $results); // Restrict area and context, incompatibly - this has no results (and doesn't do a query). $querydata->contextids = [$contextc2f->id]; $results = $this->search->search($querydata); $this->assert_result_titles([], $results); } /** * Tests searching for results in groups, either by specified group ids or based on user * access permissions. */ public function test_groups(): void { global $USER; // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and a selection of forums with different group mode. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $forum1nogroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => NOGROUPS]); $forum1separategroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => SEPARATEGROUPS]); $forum1visiblegroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => VISIBLEGROUPS]); $course2 = $generator->create_course(['fullname' => 'Course 2']); $forum2separategroups = $generator->create_module('forum', ['course' => $course2, 'groupmode' => SEPARATEGROUPS]); // Create two groups on each course. $group1a = $generator->create_group(['courseid' => $course1->id]); $group1b = $generator->create_group(['courseid' => $course1->id]); $group2a = $generator->create_group(['courseid' => $course2->id]); $group2b = $generator->create_group(['courseid' => $course2->id]); // Create search records in each activity and (where relevant) in each group. $forumgenerator = $generator->get_plugin_generator('mod_forum'); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1nogroups->id, 'name' => 'F1NG', 'message' => 'xyzzy']); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-A', 'message' => 'xyzzy', 'groupid' => $group2a->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-B', 'message' => 'xyzzy', 'groupid' => $group2b->id]); $this->search->index(); // Search as admin user should find everything. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); // Admin user manually restricts results by groups. $querydata->groupids = [$group1b->id, $group2a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-B', 'F1VG-B', 'F2SG-A'], $results); // Student enrolled in both courses but no groups. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1VG-A', 'F1VG-B'], $results); // Student enrolled in both courses and group A in both cases. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course1->id, 'student'); $generator->enrol_user($student2->id, $course2->id, 'student'); groups_add_member($group1a, $student2); groups_add_member($group2a, $student2); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1SG-A', 'F1VG-A', 'F1VG-B', 'F2SG-A'], $results); // Manually restrict results to group B in course 1. $querydata->groupids = [$group1b->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1VG-B'], $results); // Manually restrict results to group A in course 1. $querydata->groupids = [$group1a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-A', 'F1VG-A'], $results); // Manager enrolled in both courses (has access all groups). $manager = $generator->create_user(); $generator->enrol_user($manager->id, $course1->id, 'manager'); $generator->enrol_user($manager->id, $course2->id, 'manager'); $this->setUser($manager); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); } /** * Tests searching for results restricted to specific user id(s). */ public function test_user_restriction(): void { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create a course, a forum, and a glossary. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); $glossary = $generator->create_module('glossary', ['course' => $course->id]); // Create 3 user accounts, all enrolled as students on the course. $user1 = $generator->create_user(); $user2 = $generator->create_user(); $user3 = $generator->create_user(); $generator->enrol_user($user1->id, $course->id, 'student'); $generator->enrol_user($user2->id, $course->id, 'student'); $generator->enrol_user($user3->id, $course->id, 'student'); // All users create a forum discussion. $forumgen = $generator->get_plugin_generator('mod_forum'); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']); // Two of the users create entries in the glossary. $glossarygen = $generator->get_plugin_generator('mod_glossary'); $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh', 'userid' => $user1->id]); $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh', 'userid' => $user3->id]); // Index the data. $this->search->index(); // Search without user restriction should find everything. $querydata = new \stdClass(); $querydata->q = 'plugh'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results); // Restriction to user 3 only. $querydata->userids = [$user3->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry3', 'Post3'], $results); // Restriction to users 1 and 2. $querydata->userids = [$user1->id, $user2->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Post1', 'Post2'], $results); // Restriction to users 1 and 2 combined with context restriction. $querydata->contextids = [\context_module::instance($glossary->cmid)->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1'], $results); // Restriction to users 1 and 2 combined with area restriction. unset($querydata->contextids); $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Post1', 'Post2'], $results); } /** * Tests searching for results containing words in italic text. (This used to fail.) */ public function test_italics(): void { global $USER; // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // As admin user, create forum discussions with various words in italics or with underlines. $this->setAdminUser(); $forumgen = $generator->get_plugin_generator('mod_forum'); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post1', 'message' => '

This is a post about frogs.

']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post2', 'message' => '

This is a post about toads and zombies.

']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post3', 'message' => '

This is a post about toads_and_zombies.

']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post4', 'message' => '

This is a post about _leading and trailing_ underlines.

']); // Index the data. $this->search->index(); // Search for 'frogs' should find the post. $querydata = new \stdClass(); $querydata->q = 'frogs'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post1'], $results); // Search for 'toads' or 'zombies' should find post 2 (and not 3)... $querydata->q = 'toads'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post2'], $results); $querydata->q = 'zombies'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post2'], $results); // Search for 'toads_and_zombies' should find post 3. $querydata->q = 'toads_and_zombies'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post3'], $results); // Search for '_leading' or 'trailing_' should find post 4. $querydata->q = '_leading'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post4'], $results); $querydata->q = 'trailing_'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post4'], $results); } /** * Asserts that the returned documents have the expected titles (regardless of order). * * @param string[] $expected List of expected document titles * @param \core_search\document[] $results List of returned documents */ protected function assert_result_titles(array $expected, array $results) { $titles = []; foreach ($results as $result) { $titles[] = $result->get('title'); } sort($titles); sort($expected); $this->assertEquals($expected, $titles); } /** * Tests the get_supported_orders function for contexts where we can only use relevance * (system, category). */ public function test_get_supported_orders_relevance_only(): void { global $DB; // System or category context: relevance only. $orders = $this->engine->get_supported_orders(\context_system::instance()); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}'); $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid)); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); } /** * Tests the get_supported_orders function for contexts where we support location as well * (course, activity, block). */ public function test_get_supported_orders_relevance_and_location(): void { global $DB; // Test with course context. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'Frogs']); $coursecontext = \context_course::instance($course->id); $orders = $this->engine->get_supported_orders($coursecontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Course: Frogs', $orders['location']); // Test with activity context. $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']); $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid)); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Page: Toads', $orders['location']); // Test with block context. $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => '']; $blockid = $DB->insert_record('block_instances', $instance); $blockcontext = \context_block::instance($blockid); $orders = $this->engine->get_supported_orders($blockcontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Block: Text', $orders['location']); } /** * Tests ordering by relevance vs location. */ public function test_ordering(): void { // Create 2 courses and 2 activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); $course1page = $generator->create_module('page', ['course' => $course1]); $course1pagecontext = \context_module::instance($course1page->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2']); $course2context = \context_course::instance($course2->id); $course2page = $generator->create_module('page', ['course' => $course2]); $course2pagecontext = \context_module::instance($course2page->cmid); // Create one search record in each activity and course. $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy'); $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); $this->search->index(); // Default search works by relevance so the one with both words should be top. $querydata = new \stdClass(); $querydata->q = 'xyzzy plugh'; $results = $this->search->search($querydata); $this->assertCount(4, $results); $this->assertEquals('C2P', $results[0]->get('title')); // Same if you explicitly specify relevance. $querydata->order = 'relevance'; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // If you specify order by location and you are in C2 or C2P then results are the same. $querydata->order = 'location'; $querydata->context = $course2context; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); $querydata->context = $course2pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // But if you are in C1P then you get different results (C1P first). $querydata->context = $course1pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C1P', $results[0]->get('title')); } /** * Tests with bogus content (that can be entered into Moodle) to see if it crashes. */ public function test_bogus_content(): void { $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); // It is possible to enter into a Moodle database content containing these characters, // which are Unicode non-characters / byte order marks. If sent to Solr, these cause // failures. $boguscontent = html_entity_decode('￾', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('￿', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters // in total. Here are some of them - these work OK for me but it may depend on platform. $boguscontent = html_entity_decode('﷐', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('﷯', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('🿾', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('􏿿', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Do the indexing (this will check it doesn't throw warnings). $this->search->index(); // Confirm that all 6 documents are found in search. $querydata = new \stdClass(); $querydata->q = 'frog'; $results = $this->search->search($querydata); $this->assertCount(6, $results); } /** * Adds a record to the mock search area, so that the search engine can find it later. * * @param int $courseid Course id * @param int $contextid Context id * @param string $title Title for search index * @param string $content Content for search index */ protected function create_search_record($courseid, $contextid, $title, $content) { $record = new \stdClass(); $record->content = $content; $record->title = $title; $record->courseid = $courseid; $record->contextid = $contextid; $this->generator->create_record($record); } /** * Tries out deleting data for a context or a course. */ public function test_deleted_contexts_and_courses(): void { // Create some courses and activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); $course1page1 = $generator->create_module('page', ['course' => $course1]); $course1page1context = \context_module::instance($course1page1->cmid); $course1page2 = $generator->create_module('page', ['course' => $course1]); $course1page2context = \context_module::instance($course1page2->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2']); $course2context = \context_course::instance($course2->id); $course2page = $generator->create_module('page', ['course' => $course2]); $course2pagecontext = \context_module::instance($course2page->cmid); // Create one search record in each activity and course. $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); $this->create_search_record($course1->id, $course1page1context->id, 'C1P1', 'Xyzzy'); $this->create_search_record($course1->id, $course1page2context->id, 'C1P2', 'Xyzzy'); $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); $this->search->index(); // By default we have all results. $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']); // Say we delete the course2pagecontext... $this->engine->delete_index_for_context($course2pagecontext->id); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']); // Now delete the second course... $this->engine->delete_index_for_course($course2->id); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2']); // Finally let's delete using Moodle functions to check that works. Single context first. course_delete_module($course1page1->cmid); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P2']); delete_course($course1, false); $this->assert_raw_solr_query_result('content:xyzzy', []); } /** * Specific test of the add_document_batch function (also used in many other tests). */ public function test_add_document_batch(): void { // Get a default document. $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Now create 5 similar documents. $docs = []; for ($i = 1; $i <= 5; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $doc->set('title', 'Batch ' . $i); $docs[$i] = $doc; } // Document 3 has a file attached. $fs = get_file_storage(); $filerecord = new \stdClass(); $filerecord->content = 'Some FileContents'; $file = $this->generator->create_file($filerecord); $docs[3]->add_stored_file($file); // Add all these documents to the search engine. $this->assertEquals([5, 0, 1], $this->engine->add_document_batch($docs, true)); $this->engine->area_index_complete($area->get_area_id()); // Check all documents were indexed. $querydata = new \stdClass(); $querydata->q = 'Batch'; $results = $this->search->search($querydata); $this->assertCount(5, $results); // Check it also finds based on the file. $querydata->q = 'FileContents'; $results = $this->search->search($querydata); $this->assertCount(1, $results); } /** * Tests the batching logic, specifically the limit to 100 documents per * batch, and not batching very large documents. */ public function test_batching(): void { $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Up to 100 documents in 1 batch. $docs = []; for ($i = 1; $i <= 100; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(1, $batches); // More than 100 needs 2 batches. $docs = []; for ($i = 1; $i <= 101; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(2, $batches); // Small number but with some large documents that aren't batched. $docs = []; for ($i = 1; $i <= 10; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } // This one is just small enough to fit. $docs[3]->set('content', str_pad('xyzzy ', 1024 * 1024, 'x')); // These two don't fit. $docs[5]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x')); $docs[6]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x')); [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(3, $batches); // Check that all 3 of the large documents (added as batch or not) show up in results. $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assertCount(3, $results); } /** * Tests with large documents. The point of this test is that we stop batching * documents if they are bigger than 1MB, and the maximum batch count is 100, * so the maximum size batch will be about 100 1MB documents. */ public function test_add_document_batch_large(): void { // This test is a bit slow and not that important to run every time... if (!PHPUNIT_LONGTEST) { $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); } // Get a default document. $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Now create 100 large documents. $size = 1024 * 1024; $docs = []; for ($i = 1; $i <= 100; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $doc->set('title', 'Batch ' . $i); $doc->set('content', str_pad('', $size, 'Long text ' . $i . '. ', STR_PAD_RIGHT) . ' xyzzy'); $docs[$i] = $doc; } // Add all these documents to the search engine. $this->engine->add_document_batch($docs, true); $this->engine->area_index_complete($area->get_area_id()); // Check all documents were indexed, searching for text at end. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assertCount(100, $results); // Search for specific text that's only in one. $querydata->q = '42'; $results = $this->search->search($querydata); $this->assertCount(1, $results); } /** * Tests that the get_status function works OK on the real server (there are more detailed * tests for this function in {@see mock_engine_test}). * * @covers \search_solr\check\connection */ public function test_get_status(): void { $status = $this->engine->get_status(5); $this->assertTrue($status['connected']); $this->assertTrue($status['foundcore']); $this->assertGreaterThan(0, $status['indexsize']); } /** * Carries out a raw Solr query using the Solr basic query syntax. * * This is used to test data contained in the index without going through Moodle processing. * * @param string $q Search query * @param string[] $expected Expected titles of results, in alphabetical order */ protected function assert_raw_solr_query_result(string $q, array $expected) { $solr = $this->engine->get_search_client_public(); $query = new \SolrQuery($q); $results = $solr->query($query)->getResponse()->response->docs; if ($results) { $titles = array_map(function($x) { return $x->title; }, $results); sort($titles); } else { $titles = []; } $this->assertEquals($expected, $titles); } } engine/solr/tests/mock_engine_test.php000064400000062734152265255700014205 0ustar00. namespace search_solr; /** * Solr search engine unit tests that can operate using a mock http_client and without creating a * search manager instance. * * These tests can run without the solr PHP extension. * * All 'realistic' tests of searching (e.g. index something then see if it is found by search) * require a real Solr instance for testing and should be placed in {@see engine_test}. * Tests that don't rely heavily on the real search functionality, or where we need to simulate * multiple different ways of configuring the search infrastructure, or unusual failures in * communication, may be better suited for this mock test approach. * * @package search_solr * @category test * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \search_solr\engine */ final class mock_engine_test extends \advanced_testcase { protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Minimal configuration. set_config('server_hostname', 'host.invalid', 'search_solr'); set_config('indexname', 'myindex', 'search_solr'); // This is not necessary on my setup but in GitHub Actions, the server_port is set to '' // instead of default 8983. set_config('server_port', '8983', 'search_solr'); } /** * Tests {@see engine::get_server_url}. */ public function test_get_server_url(): void { // Basic URL. $engine = new engine(); $this->assertEquals( 'http://host.invalid:8983/solr/', $engine->get_server_url('')->out(false), ); // Same but with specified path. $this->assertEquals( 'http://host.invalid:8983/solr/twiddle', $engine->get_server_url('twiddle')->out(false), ); // Slash at start of path will be stripped. $this->assertEquals( 'http://host.invalid:8983/solr/twiddle', $engine->get_server_url('/twiddle')->out(false), ); // Turn on https. Due to the way the port setting works, which is bad, this will still have // the default not-secure port (even though the 'default' on the setting page will now be // shown as 8443, hmm). User has to change it manually. set_config('secure', '1', 'search_solr'); $engine = new engine(); $this->assertEquals( 'https://host.invalid:8983/solr/', $engine->get_server_url('')->out(false), ); // Change port from default. User has to do this manually when enabling secure. set_config('server_port', '8443', 'search_solr'); $engine = new engine(); $this->assertEquals( 'https://host.invalid:8443/solr/', $engine->get_server_url('')->out(false), ); } /** * Tests {@see engine::get_connection_url}. */ public function test_get_connection_url(): void { // Basic URL. $engine = new engine(); $this->assertEquals( 'http://host.invalid:8983/solr/myindex/', $engine->get_connection_url('')->out(false), ); } /** * Tests {@see engine::raw_get_request()} with no auth settings. */ public function test_raw_get_request_no_auth(): void { $engine = new engine(); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // When there is no auth, there aren't many options, just timeout. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); // Timeout can be changed in config. set_config('server_timeout', '10', 'search_solr'); $engine = new engine(); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/frog', [ 'connect_timeout' => 10, 'read_timeout' => 10, ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); } /** * Tests {@see engine::raw_get_request()} with basic auth settings. */ public function test_raw_get_request_basic_auth(): void { set_config('server_username', 'u', 'search_solr'); set_config('server_password', 'p', 'search_solr'); $engine = new engine(); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // Basic auth works with an 'auth' option. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'auth' => ['u', 'p'], ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); } /** * Tests {@see engine::raw_get_request()} with a supplied user certificate. */ public function test_raw_get_request_user_cert(): void { set_config('secure', '1', 'search_solr'); set_config('ssl_cert', '/tmp/cert.pem', 'search_solr'); $engine = new engine(); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // User cert auth uses the 'cert' parameter, with or without a key. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'cert' => '/tmp/cert.pem', ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); } /** * Tests {@see engine::raw_get_request()} with a user key (with or without password). */ public function test_raw_get_request_user_key(): void { set_config('secure', '1', 'search_solr'); set_config('ssl_key', '/tmp/key.pem', 'search_solr'); $engine = new engine(); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // User cert auth uses the 'cert' parameter, with or without a key. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'ssl_key' => '/tmp/key.pem', ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); set_config('ssl_keypassword', 'frog', 'search_solr'); $engine = new engine(); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'ssl_key' => ['/tmp/key.pem', 'frog'], ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); } /** * Tests {@see engine::raw_get_request()} with a certificate bundle for verifying the server. */ public function test_raw_get_request_certificate_bundle(): void { set_config('secure', '1', 'search_solr'); set_config('ssl_cainfo', '/tmp/allthecerts.pem', 'search_solr'); $engine = new engine(); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // User cert auth uses the 'cert' parameter, with or without a key. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'verify' => '/tmp/allthecerts.pem', ], )->willReturn($response); $this->assertEquals($response, $engine->raw_get_request('frog')); } /** * Tests {@see engine::raw_get_request()} with a certificate folder for verifying the server. * Guzzle doesn't support a certificate folder (curl does) so this code makes a bundle in the * localcache area. */ public function test_raw_get_request_certificate_folder(): void { global $CFG; // Make a directory full of fake .pem files. $temp = make_request_directory(); file_put_contents($temp . '/0.pem', "PEM0\n"); file_put_contents($temp . '/1.pem', "PEM1\n"); file_put_contents($temp . '/2.txt', "TXT2\n"); set_config('secure', '1', 'search_solr'); set_config('ssl_capath', $temp, 'search_solr'); $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); // Party like it's 13 February 2009. $time = 1234567890; $this->mock_clock_with_frozen($time); // User cert auth uses the 'cert' parameter, with or without a key. $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); // The filename is the hash of the capath setting plus current time. $combinedfile = $CFG->dataroot . '/localcache/search_solr/capath.' . sha1($temp) . '.1234567890'; $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'verify' => $combinedfile, ], )->willReturn($response); $engine = new engine(); $this->assertEquals($response, $engine->raw_get_request('frog')); // Check the file actually is the .pem files concatenated. $this->assertEquals("PEM0\n\n\nPEM1\n\n\n", file_get_contents($combinedfile)); // Let's add another .pem file. file_put_contents($temp . '/3.pem', "PEM3\n"); // 9 minutes 59 seconds later, it will still use the cached version (same file). $time += 599; $this->mock_clock_with_frozen($time); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'verify' => $combinedfile, ], )->willReturn($response); $engine = new engine(); $this->assertEquals($response, $engine->raw_get_request('frog')); $this->assertEquals("PEM0\n\n\nPEM1\n\n\n", file_get_contents($combinedfile)); // 10 minutes later, it will make a new cached version. $time += 1; $this->mock_clock_with_frozen($time); $combinedfile2 = $CFG->dataroot . '/localcache/search_solr/capath.' . sha1($temp) . '.1234568490'; $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'verify' => $combinedfile2, ], )->willReturn($response); $engine = new engine(); $this->assertEquals($response, $engine->raw_get_request('frog')); $this->assertEquals("PEM0\n\n\nPEM1\n\n\nPEM3\n\n\n", file_get_contents($combinedfile2)); // The old file is still there. $this->assertEquals("PEM0\n\n\nPEM1\n\n\n", file_get_contents($combinedfile)); // Go another minute. We're still using the same combined file... $time += 60; $this->mock_clock_with_frozen($time); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'https://host.invalid:8983/solr/frog', [ 'connect_timeout' => 30, 'read_timeout' => 30, 'verify' => $combinedfile2, ], )->willReturn($response); $engine = new engine(); $this->assertEquals($response, $engine->raw_get_request('frog')); $this->assertEquals("PEM0\n\n\nPEM1\n\n\nPEM3\n\n\n", file_get_contents($combinedfile2)); // But now it will delete the old one. $this->assertFalse(file_exists($combinedfile)); } /** * Tests the {@see engine::get_status()} function when there is an exception connecting. */ public function test_get_status_exception_connecting(): void { $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willThrowException(new \coding_exception('ex')); $engine = new engine(); $status = $engine->get_status(); $this->assertFalse($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals( 'Exception occurred: Coding error detected, it must be fixed by a programmer: ex', $status['error'], ); $this->assertInstanceOf(\coding_exception::class, $status['exception']); } /** * Tests the {@see engine::get_status()} function when the server returns 404. */ public function test_get_status_bad_http_status(): void { $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); $response->method('getStatusCode')->willReturn(404); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertFalse($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals('Unsuccessful status code: 404', $status['error']); } /** * Creates a mock ResponseInterface with a body containing the specified string. * * @param string $body Body content * @return \Psr\Http\Message\ResponseInterface Interface */ protected function get_fake_response(string $body): \Psr\Http\Message\ResponseInterface { $response = $this->createStub(\Psr\Http\Message\ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $stream = $this->createStub(\Psr\Http\Message\StreamInterface::class); $response->method('getBody')->willReturn($stream); $stream->method('getContents')->willReturn($body); return $response; } /** * Tests the {@see engine::get_status()} function when the server returns invalid JSON. * In real life this would only be likely to happen if the server is down and a load balancer * in front of it for some crazy reason interposes a page with status 200. */ public function test_get_status_not_json(): void { $response = $this->get_fake_response('notjson'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertFalse($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals('Invalid JSON', $status['error']); } /** * Tests the {@see engine::get_status()} function when the server returns an empty response. * * This could maybe happen if the server has been configured, but not fully initialised. */ public function test_get_status_no_cores(): void { $response = $this->get_fake_response('{}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals('Unexpected JSON: no core status', $status['error']); } /** * Tests the {@see engine::get_status()} function when the server returns a core without a name * we can read. * * In real usage this should only happen if the Solr REST interface changes unexpectedly. */ public function test_get_status_core_no_name(): void { // A core with no name (in its 'name' field, the 'frog' key is ignored). $response = $this->get_fake_response('{"status":{"frog":{}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals('Unexpected JSON: core has no name', $status['error']); } /** * Tests the {@see engine::get_status()} function when the server doesn't return status for a * core that matches the index name in Moodle config. * * In real usage this could happen if the index got wiped from search or something. */ public function test_get_status_no_matching_core(): void { // Core is not the one we're looking for. $response = $this->get_fake_response('{"status":{"frog":{"name":"frog"}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertFalse($status['foundcore']); $this->assertEquals('Could not find core matching myindex', $status['error']); } /** * Tests the {@see engine::get_status()} function when the server returns a core without index * information. * * In real usage this should only happen if the Solr REST interface changes unexpectedly. There * is a parameter to not receive index information, but we don't use it. */ public function test_get_status_core_no_index(): void { // Core exists but has no index object. $response = $this->get_fake_response('{"status":{"myindex":{"name":"myindex"}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertTrue($status['foundcore']); $this->assertEquals('Unexpected JSON: core has no index', $status['error']); } /** * Tests the {@see engine::get_status()} function when the server returns index information * without size. * * In real usage this should only happen if the Solr REST interface changes unexpectedly. */ public function test_get_status_core_index_no_size(): void { // Core index objects doesn't have a size. $response = $this->get_fake_response('{"status":{"myindex":{"name":"myindex","index":{}}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertTrue($status['foundcore']); $this->assertEquals('Unexpected JSON: core index has no sizeInBytes', $status['error']); } /** * Tests the {@see engine::get_status()} function when all desired data is present, using a * single-instance Solr configuration. */ public function test_get_status_success_single_server(): void { // Core index complete with size. $response = $this->get_fake_response('{"status":{"myindex":{"name":"myindex",' . '"index":{"sizeInBytes":123}}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertTrue($status['foundcore']); $this->assertEquals(123, $status['indexsize']); } /** * Tests the {@see engine::get_status()} function when all desired data is present, using a * multiple-instance (SolrCloud) configuration. */ public function test_get_status_success_solr_cloud(): void { // Index with size, in cloud replica. These have a different name for each node but a // 'collection' field with the original index name. $response = $this->get_fake_response('{"status":{"replica1":{"name":"replica1",' . '"cloud":{"collection":"myindex"},"index":{"sizeInBytes":123}}}}'); $mockedclient = $this->createMock(\core\http_client::class); \core\di::set(\core\http_client::class, $mockedclient); $mockedclient->expects($this->once())->method('get')->with( 'http://host.invalid:8983/solr/admin/cores', [ 'connect_timeout' => 30, 'read_timeout' => 30, ], )->willReturn($response); $engine = new engine(); $status = $engine->get_status(); $this->assertTrue($status['connected']); $this->assertTrue($status['foundcore']); $this->assertEquals(123, $status['indexsize']); } } engine/solr/settings.php000064400000016566152265255700011370 0ustar00. /** * Solr search engine settings. * * @package search_solr * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); if ($ADMIN->fulltree) { if (!during_initial_install()) { if (!function_exists('solr_get_version')) { $settings->add(new admin_setting_heading('search_solr_settings', '', get_string('extensionerror', 'search_solr'))); } else { $settings->add(new admin_setting_heading('search_solr_connection', new lang_string('connectionsettings', 'search_solr'), '')); $settings->add(new admin_setting_configtext('search_solr/server_hostname', new lang_string('solrserverhostname', 'search_solr'), new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_HOST)); $settings->add(new admin_setting_configtext('search_solr/indexname', new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT)); $settings->add(new admin_setting_configcheckbox('search_solr/secure', new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0)); $secure = get_config('search_solr', 'secure'); $defaultport = !empty($secure) ? 8443 : 8983; $settings->add(new admin_setting_configtext('search_solr/server_port', new lang_string('solrhttpconnectionport', 'search_solr'), '', $defaultport, PARAM_INT)); $settings->add(new admin_setting_configtext('search_solr/server_username', new lang_string('solrauthuser', 'search_solr'), '', '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/server_password', new lang_string('solrauthpassword', 'search_solr'), '', '')); $settings->add(new admin_setting_configtext('search_solr/server_timeout', new lang_string('solrhttpconnectiontimeout', 'search_solr'), new lang_string('solrhttpconnectiontimeout_desc', 'search_solr'), 30, PARAM_INT)); $settings->add(new admin_setting_configtext('search_solr/ssl_cert', new lang_string('solrsslcert', 'search_solr'), new lang_string('solrsslcert_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/ssl_key', new lang_string('solrsslkey', 'search_solr'), new lang_string('solrsslkey_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/ssl_keypassword', new lang_string('solrsslkeypassword', 'search_solr'), new lang_string('solrsslkeypassword_desc', 'search_solr'), '')); $settings->add(new admin_setting_configtext('search_solr/ssl_cainfo', new lang_string('solrsslcainfo', 'search_solr'), new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/ssl_capath', new lang_string('solrsslcapath', 'search_solr'), new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext( 'search_solr/indexsizelimit', new lang_string('indexsizelimit', 'search_solr'), new lang_string('indexsizelimit_desc', 'search_solr'), 0, PARAM_INT, )); $settings->add(new admin_setting_heading('search_solr_fileindexing', new lang_string('fileindexsettings', 'search_solr'), '')); $settings->add(new admin_setting_configcheckbox('search_solr/fileindexing', new lang_string('fileindexing', 'search_solr'), new lang_string('fileindexing_help', 'search_solr'), 1)); $settings->add(new admin_setting_configtext('search_solr/maxindexfilekb', new lang_string('maxindexfilekb', 'search_solr'), new lang_string('maxindexfilekb_help', 'search_solr'), '2097152', PARAM_INT)); // Alternate connection. $settings->add(new admin_setting_heading('search_solr_alternatesettings', new lang_string('searchalternatesettings', 'admin'), new lang_string('searchalternatesettings_desc', 'admin'))); $settings->add(new admin_setting_configtext('search_solr/alternateserver_hostname', new lang_string('solrserverhostname', 'search_solr'), new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_HOST)); $settings->add(new admin_setting_configtext('search_solr/alternateindexname', new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT)); $settings->add(new admin_setting_configcheckbox('search_solr/alternatesecure', new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0)); $secure = get_config('search_solr', 'alternatesecure'); $defaultport = !empty($secure) ? 8443 : 8983; $settings->add(new admin_setting_configtext('search_solr/alternateserver_port', new lang_string('solrhttpconnectionport', 'search_solr'), '', $defaultport, PARAM_INT)); $settings->add(new admin_setting_configtext('search_solr/alternateserver_username', new lang_string('solrauthuser', 'search_solr'), '', '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/alternateserver_password', new lang_string('solrauthpassword', 'search_solr'), '', '')); $settings->add(new admin_setting_configtext('search_solr/alternatessl_cert', new lang_string('solrsslcert', 'search_solr'), new lang_string('solrsslcert_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/alternatessl_key', new lang_string('solrsslkey', 'search_solr'), new lang_string('solrsslkey_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/alternatessl_keypassword', new lang_string('solrsslkeypassword', 'search_solr'), new lang_string('solrsslkeypassword_desc', 'search_solr'), '')); $settings->add(new admin_setting_configtext('search_solr/alternatessl_cainfo', new lang_string('solrsslcainfo', 'search_solr'), new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/alternatessl_capath', new lang_string('solrsslcapath', 'search_solr'), new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW)); } } } index.php000064400000016027152265255700006403 0ustar00. /** * Global Search index page for entering queries and display of results * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../config.php'); $page = optional_param('page', 0, PARAM_INT); $q = optional_param('q', '', PARAM_NOTAGS); $title = optional_param('title', '', PARAM_NOTAGS); $contextid = optional_param('context', 0, PARAM_INT); $cat = optional_param('cat', '', PARAM_NOTAGS); $mycoursesonly = optional_param('mycoursesonly', 0, PARAM_INT); if (\core_search\manager::is_search_area_categories_enabled()) { $cat = \core_search\manager::get_search_area_category_by_name($cat); } // Moving areaids, courseids, timestart, and timeend further down as they might come as an array if they come from the form. $context = context_system::instance(); $pagetitle = get_string('globalsearch', 'search'); $PAGE->set_context($context); $PAGE->set_pagelayout('standard'); $PAGE->set_title($pagetitle); $PAGE->set_heading($pagetitle); if (!empty($CFG->forcelogin)) { require_login(); } require_capability('moodle/search:query', $context); $searchrenderer = $PAGE->get_renderer('core_search'); if (\core_search\manager::is_global_search_enabled() === false) { $PAGE->set_url(new moodle_url('/search/index.php')); echo $OUTPUT->header(); echo $searchrenderer->render_search_disabled(); echo $OUTPUT->footer(); exit; } $search = \core_search\manager::instance(true, true); // Set up custom data for form. $customdata = ['searchengine' => $search->get_engine()->get_plugin_name()]; if ($contextid) { // When a context is supplied, check if it's within course level. If so, show dropdown. $context = context::instance_by_id($contextid); $coursecontext = $context->get_course_context(false); if ($coursecontext) { $searchwithin = []; $searchwithin[''] = get_string('everywhere', 'search'); $searchwithin['course'] = $coursecontext->get_context_name(); if ($context->contextlevel != CONTEXT_COURSE) { $searchwithin['context'] = $context->get_context_name(); if ($context->contextlevel == CONTEXT_MODULE) { $customdata['withincmid'] = $context->instanceid; } } $customdata['searchwithin'] = $searchwithin; $customdata['withincourseid'] = $coursecontext->instanceid; } } // Get available ordering options from search engine. $customdata['orderoptions'] = $search->get_engine()->get_supported_orders($context); if ($cat instanceof \core_search\area_category) { $customdata['cat'] = $cat->get_name(); } $mform = new \core_search\output\form\search(null, $customdata); $data = $mform->get_data(); if (!$data && $q) { // Data can also come from the URL. $data = new stdClass(); $data->q = $q; $data->title = $title; $areaids = optional_param('areaids', '', PARAM_RAW); if (!empty($areaids)) { $areaids = explode(',', $areaids); $data->areaids = clean_param_array($areaids, PARAM_ALPHANUMEXT); } $courseids = optional_param('courseids', '', PARAM_RAW); if (!empty($courseids)) { $courseids = explode(',', $courseids); $data->courseids = clean_param_array($courseids, PARAM_INT); } $data->timestart = optional_param('timestart', 0, PARAM_INT); $data->timeend = optional_param('timeend', 0, PARAM_INT); $data->context = $contextid; $data->mycoursesonly = $mycoursesonly; $mform->set_data($data); } // Convert the 'search within' option, if used, to course or context restrictions. if ($data && !empty($data->searchwithin)) { switch ($data->searchwithin) { case 'course': $data->courseids = [$coursecontext->instanceid]; break; case 'context': $data->courseids = [$coursecontext->instanceid]; $data->contextids = [$context->id]; break; } } // Inform search engine about source context. if (!empty($context) && $data) { $data->context = $context; } if ($data && $cat instanceof \core_search\area_category) { $data->cat = $cat->get_name(); } // Set the page URL. $urlparams = array('page' => $page); if ($data) { $urlparams['q'] = $data->q; $urlparams['title'] = $data->title; if (!empty($data->areaids)) { $urlparams['areaids'] = implode(',', $data->areaids); } if (!empty($data->courseids)) { $urlparams['courseids'] = implode(',', $data->courseids); } $urlparams['timestart'] = $data->timestart; $urlparams['timeend'] = $data->timeend; $urlparams['mycoursesonly'] = isset($data->mycoursesonly) ? $data->mycoursesonly : 0; } if ($cat instanceof \core_search\area_category) { $urlparams['cat'] = $cat->get_name(); } $url = new moodle_url('/search/index.php', $urlparams); $PAGE->set_url($url); // We are ready to render. echo $OUTPUT->header(); // Unlock the session only after outputting the header as this modifies the session cachestore. \core\session\manager::write_close(); // Get the results. if ($data) { $results = $search->paged_search($data, $page); } // Show search information if configured by system administrator. if ($CFG->searchbannerenable && $CFG->searchbanner) { echo $OUTPUT->notification(format_text($CFG->searchbanner, FORMAT_HTML), 'notifywarning'); } if ($errorstr = $search->get_engine()->get_query_error()) { echo $OUTPUT->notification(get_string('queryerror', 'search', $errorstr), 'notifyproblem'); } else if (empty($results->totalcount) && !empty($data)) { echo $OUTPUT->notification(get_string('noresults', 'search'), 'notifymessage'); } $mform->display(); if (!empty($results)) { $topresults = $search->search_top($data); if (!empty($topresults)) { echo $searchrenderer->render_top_results($topresults); } echo $searchrenderer->render_results($results->results, $results->actualpage, $results->totalcount, $url, $cat); \core_search\manager::trigger_search_results_viewed([ 'q' => $data->q, 'page' => $page, 'title' => $data->title, 'areaids' => !empty($data->areaids) ? $data->areaids : array(), 'courseids' => !empty($data->courseids) ? $data->courseids : array(), 'timestart' => isset($data->timestart) ? $data->timestart : 0, 'timeend' => isset($data->timeend) ? $data->timeend : 0 ]); } echo $OUTPUT->footer(); amd/src/form-search-user-selector.js000064400000005502152265255700013445 0ustar00// 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 . /** * Search user selector module. * * @module core_search/form-search-user-selector * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) { return /** @alias module:core_search/form-search-user-selector */ { processResults: function(selector, results) { var users = []; $.each(results, function(index, user) { users.push({ value: user.id, label: user._label }); }); return users; }, transport: function(selector, query, success, failure) { var promise; // Search within specific course if known and if the 'search within' dropdown is set // to search within course or activity. var args = {query: query}; var courseid = $(selector).attr('withincourseid'); if (typeof courseid !== "undefined" && $('#id_searchwithin').val() !== '') { args.courseid = courseid; } else { args.courseid = 0; } // Call AJAX request. promise = Ajax.call([{methodname: 'core_search_get_relevant_users', args: args}]); // When AJAX request returns, handle the results. promise[0].then(function(results) { var promises = []; // Render label with user name and picture. $.each(results, function(index, user) { promises.push(Templates.render('core_search/form-user-selector-suggestion', user)); }); // Apply the label to the results. return $.when.apply($.when, promises).then(function() { var args = arguments; var i = 0; $.each(results, function(index, user) { user._label = args[i++]; }); success(results); return; }); }).fail(failure); } }; }); amd/build/form-search-user-selector.min.js.map000064400000010002152265255700015302 0ustar00{"version":3,"file":"form-search-user-selector.min.js","sources":["../src/form-search-user-selector.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Search user selector module.\n *\n * @module core_search/form-search-user-selector\n * @copyright 2017 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {\n\n return /** @alias module:core_search/form-search-user-selector */ {\n\n processResults: function(selector, results) {\n var users = [];\n $.each(results, function(index, user) {\n users.push({\n value: user.id,\n label: user._label\n });\n });\n return users;\n },\n\n transport: function(selector, query, success, failure) {\n var promise;\n\n // Search within specific course if known and if the 'search within' dropdown is set\n // to search within course or activity.\n var args = {query: query};\n var courseid = $(selector).attr('withincourseid');\n if (typeof courseid !== \"undefined\" && $('#id_searchwithin').val() !== '') {\n args.courseid = courseid;\n } else {\n args.courseid = 0;\n }\n\n // Call AJAX request.\n promise = Ajax.call([{methodname: 'core_search_get_relevant_users', args: args}]);\n\n // When AJAX request returns, handle the results.\n promise[0].then(function(results) {\n var promises = [];\n\n // Render label with user name and picture.\n $.each(results, function(index, user) {\n promises.push(Templates.render('core_search/form-user-selector-suggestion', user));\n });\n\n // Apply the label to the results.\n return $.when.apply($.when, promises).then(function() {\n var args = arguments;\n var i = 0;\n $.each(results, function(index, user) {\n user._label = args[i++];\n });\n success(results);\n return;\n });\n\n }).fail(failure);\n }\n\n };\n\n});\n"],"names":["define","$","Ajax","Templates","processResults","selector","results","users","each","index","user","push","value","id","label","_label","transport","query","success","failure","args","courseid","attr","val","call","methodname","then","promises","render","when","apply","arguments","i","fail"],"mappings":";;;;;;;AAuBAA,+CAAO,CAAC,SAAU,YAAa,mBAAmB,SAASC,EAAGC,KAAMC,iBAEE,CAE9DC,eAAgB,SAASC,SAAUC,aAC3BC,MAAQ,UACZN,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BH,MAAMI,KAAK,CACPC,MAAOF,KAAKG,GACZC,MAAOJ,KAAKK,YAGbR,OAGXS,UAAW,SAASX,SAAUY,MAAOC,QAASC,aAKtCC,KAAO,CAACH,MAAOA,OACfI,SAAWpB,EAAEI,UAAUiB,KAAK,uBACR,IAAbD,UAA4D,KAAhCpB,EAAE,oBAAoBsB,MACzDH,KAAKC,SAAWA,SAEhBD,KAAKC,SAAW,EAIVnB,KAAKsB,KAAK,CAAC,CAACC,WAAY,iCAAkCL,KAAMA,QAGlE,GAAGM,MAAK,SAASpB,aACjBqB,SAAW,UAGf1B,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BiB,SAAShB,KAAKR,UAAUyB,OAAO,4CAA6ClB,UAIzET,EAAE4B,KAAKC,MAAM7B,EAAE4B,KAAMF,UAAUD,MAAK,eACnCN,KAAOW,UACPC,EAAI,EACR/B,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BA,KAAKK,OAASK,KAAKY,QAEvBd,QAAQZ,eAIb2B,KAAKd"}amd/build/form-search-user-selector.min.js000064400000002210152265255700014530 0ustar00/** * Search user selector module. * * @module core_search/form-search-user-selector * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("core_search/form-search-user-selector",["jquery","core/ajax","core/templates"],(function($,Ajax,Templates){return{processResults:function(selector,results){var users=[];return $.each(results,(function(index,user){users.push({value:user.id,label:user._label})})),users},transport:function(selector,query,success,failure){var args={query:query},courseid=$(selector).attr("withincourseid");void 0!==courseid&&""!==$("#id_searchwithin").val()?args.courseid=courseid:args.courseid=0,Ajax.call([{methodname:"core_search_get_relevant_users",args:args}])[0].then((function(results){var promises=[];return $.each(results,(function(index,user){promises.push(Templates.render("core_search/form-user-selector-suggestion",user))})),$.when.apply($.when,promises).then((function(){var args=arguments,i=0;$.each(results,(function(index,user){user._label=args[i++]})),success(results)}))})).fail(failure)}}})); //# sourceMappingURL=form-search-user-selector.min.js.mapcli/indexer.php000064400000007323152265255700007500 0ustar00. /** * CLI search indexer * * @package search * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define('CLI_SCRIPT', true); require(__DIR__.'/../../config.php'); require_once($CFG->libdir.'/clilib.php'); // cli only functions list($options, $unrecognized) = cli_get_params(array('help' => false, 'force' => false, 'reindex' => false, 'timelimit' => 0), array('h' => 'help', 'f' => 'force', 'r' => 'reindex', 't' => 'timelimit')); if ($unrecognized) { $unrecognized = implode("\n ", $unrecognized); cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); } if ($options['help']) { $help = "Index search data Options: -h, --help Print out this help -r, --reindex Reindex data -f, --force Allow indexer to run, even if global search is disabled. -t=, --timelimit= Stop after indexing for specified time (in seconds) Examples: \$ sudo -u www-data /usr/bin/php search/cli/indexer.php --reindex \$ sudo -u www-data /usr/bin/php search/cli/indexer.php --timelimit=300 "; echo $help; die; } if ($options['timelimit'] && $options['reindex']) { cli_error('Cannot apply time limit when reindexing'); } if (!\core_search\manager::is_global_search_enabled() && empty($options['force'])) { cli_error('Global search is disabled. Use --force if you want to force an index while disabled'); } if (!$searchengine = \core_search\manager::search_engine_instance()) { cli_error(get_string('engineserverstatus', 'search')); } if (!$searchengine->is_installed()) { cli_error('enginenotinstalled', 'search', $CFG->searchengine); } $serverstatus = $searchengine->is_server_ready(); if ($serverstatus !== true) { cli_error($serverstatus); } $globalsearch = \core_search\manager::instance(); if (empty($options['reindex'])) { if ($options['timelimit']) { $limitinfo = ' (max ' . $options['timelimit'] . ' seconds)'; $limitunderline = preg_replace('~.~', '=', $limitinfo); echo "Running index of site$limitinfo\n"; echo "=====================$limitunderline\n"; $timelimit = (int)$options['timelimit']; } else { echo "Running full index of site\n"; echo "==========================\n"; $timelimit = 0; } $before = time(); $globalsearch->index(false, $timelimit, new text_progress_trace()); // Do specific index requests with the remaining time. if ($timelimit) { $timelimit -= (time() - $before); // Only do index requests if there is a reasonable amount of time left. if ($timelimit > 1) { $globalsearch->process_index_requests($timelimit, new text_progress_trace()); } } else { $globalsearch->process_index_requests(0, new text_progress_trace()); } } else { echo "Running full reindex of site\n"; echo "============================\n"; $globalsearch->index(true, 0, new text_progress_trace()); } // Optimize index at last. $globalsearch->optimize_index(); tests/area_category_test.php000064400000007617152265255700012307 0ustar00. namespace core_search; /** * Area category unit tests. * * @package core_search * @copyright 2018 Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class area_category_test extends \advanced_testcase { /** * A helper function to get a mocked search area. * @param string $areaid * * @return \PHPUnit\Framework\MockObject\MockObject */ protected function get_mocked_area($areaid) { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_area_id')); $area = $builder->getMockForAbstractClass(); $area->method('get_area_id')->willReturn($areaid); return $area; } /** * A helper function to get a list of search areas. * * @return array */ protected function get_areas() { $areas = []; $areas[] = $this->get_mocked_area('area1'); $areas[] = 'String'; $areas[] = 1; $areas[] = '12'; $areas[] = true; $areas[] = false; $areas[] = null; $areas[] = [$this->get_mocked_area('area2')]; $areas[] = $this; $areas[] = new \stdClass(); $areas[] = $this->get_mocked_area('area3'); $areas[] = $this->get_mocked_area('area4'); return $areas; } /** * Test default values. */ public function test_default_values(): void { $category = new \core_search\area_category('test_name', 'test_visiblename'); $this->assertEquals('test_name', $category->get_name()); $this->assertEquals('test_visiblename', $category->get_visiblename()); $this->assertEquals(0, $category->get_order()); $this->assertEquals([], $category->get_areas()); } /** * Test that all get functions work as expected. */ public function test_getters(): void { $category = new \core_search\area_category('test_name', 'test_visiblename', 4, $this->get_areas()); $this->assertEquals('test_name', $category->get_name()); $this->assertEquals('test_visiblename', $category->get_visiblename()); $this->assertEquals(4, $category->get_order()); $this->assertTrue(is_array($category->get_areas())); $this->assertCount(3, $category->get_areas()); $this->assertTrue(key_exists('area1', $category->get_areas())); $this->assertTrue(key_exists('area3', $category->get_areas())); $this->assertTrue(key_exists('area4', $category->get_areas())); } /** * Test that a list of areas could be set correctly. */ public function test_list_of_areas_could_be_set(): void { $category = new \core_search\area_category('test_name', 'test_visiblename'); $this->assertEquals([], $category->get_areas()); $category->set_areas($this->get_areas()); $this->assertTrue(is_array($category->get_areas())); $this->assertCount(3, $category->get_areas()); $this->assertTrue(key_exists('area1', $category->get_areas())); $this->assertTrue(key_exists('area3', $category->get_areas())); $this->assertTrue(key_exists('area4', $category->get_areas())); } } tests/fixtures/mock_search_area.php000064400000011100152265255700013540 0ustar00. namespace core_mocksearch\search; /** * Component implementing search for testing purposes. * * @package core_search * @category phpunit * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; class mock_search_area extends \core_search\base { /** @var float If set, waits when doing the indexing query (seconds) */ protected $indexingdelay = 0; /** * Multiple context level so we can test get_areas_user_accesses. * @var int[] */ protected static $levels = [CONTEXT_COURSE, CONTEXT_USER]; /** * To make things easier, base class required config stuff. * * @return bool */ public function is_enabled() { return true; } public function get_recordset_by_timestamp($modifiedfrom = 0) { global $DB; if ($this->indexingdelay) { \testable_core_search::fake_current_time( \core_search\manager::get_current_time() + $this->indexingdelay); } $sql = "SELECT * FROM {temp_mock_search_area} WHERE timemodified >= ? ORDER BY timemodified ASC"; return $DB->get_recordset_sql($sql, array($modifiedfrom)); } /** * A helper function that will turn a record into 'data array', for use with document building. */ public function convert_record_to_doc_array($record) { $docdata = (array)unserialize($record->info); $docdata['areaid'] = $this->get_area_id(); $docdata['itemid'] = $record->id; $docdata['modified'] = $record->timemodified; return $docdata; } public function get_document($record, $options = array()) { global $USER; $info = unserialize($record->info); // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', $info->title); $doc->set('content', $info->content); $doc->set('description1', $info->description1); $doc->set('description2', $info->description2); $doc->set('contextid', $info->contextid); $doc->set('courseid', $info->courseid); $doc->set('userid', $info->userid); $doc->set('owneruserid', $info->owneruserid); $doc->set('modified', $record->timemodified); return $doc; } public function attach_files($document) { global $DB; if (!$record = $DB->get_record('temp_mock_search_area', array('id' => $document->get('itemid')))) { return; } $info = unserialize($record->info); foreach ($info->attachfileids as $fileid) { $document->add_stored_file($fileid); } } public function uses_file_indexing() { return true; } public function check_access($id) { global $DB, $USER; if ($record = $DB->get_record('temp_mock_search_area', array('id' => $id))) { $info = unserialize($record->info); if (in_array($USER->id, $info->denyuserids)) { return \core_search\manager::ACCESS_DENIED; } return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DELETED; } public function get_doc_url(\core_search\document $doc) { return new \moodle_url('/index.php'); } public function get_context_url(\core_search\document $doc) { return new \moodle_url('/index.php'); } public function get_visible_name($lazyload = false) { return 'Mock search area'; } /** * Sets a fake delay to simulate time taken doing the indexing query. * * @param float $seconds Delay in seconds for each time indexing query is called */ public function set_indexing_delay($seconds) { $this->indexingdelay = $seconds; } } tests/fixtures/testable_core_search.php000064400000010300152265255700014433 0ustar00. /** * Core search class adapted to unit test. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/mock_search_engine.php'); /** * Core search class adapted to unit test. * * Note that by default all core search areas are returned when calling get_search_areas_list, * if you want to use the mock search area you can use testable_core_search::add_search_area * although if you want to add mock search areas on top of the core ones you should call * testable_core_search::add_core_search_areas before calling testable_core_search::add_search_area. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_core_search extends \core_search\manager { /** * Attaches the mock engine to search. * * Auto enables global search. * * @param \core_search\engine|bool $searchengine * @param bool $ignored Second param just to make this compatible with base class * @return testable_core_search */ public static function instance($searchengine = false, bool $ignored = false) { // One per request, this should be purged during testing. if (self::$instance !== null) { return self::$instance; } set_config('enableglobalsearch', true); // Default to the mock one. if ($searchengine === false) { $searchengine = new \mock_search\engine(); } self::$instance = new testable_core_search($searchengine); return self::$instance; } /** * Changes visibility. * * @return array */ public function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) { return parent::get_areas_user_accesses($limitcourseids, $limitcontextids); } /** * Adds an enabled search component to the search areas list. * * @param string $areaid * @param \core_search\base $searcharea * @return void */ public function add_search_area($areaid, \core_search\base $searcharea) { self::$enabledsearchareas[$areaid] = $searcharea; self::$allsearchareas[$areaid] = $searcharea; } /** * Loads all core search areas. * * @return void */ public function add_core_search_areas() { self::get_search_areas_list(false); self::get_search_areas_list(true); } /** * Changes visibility. * * @param string $classname * @return bool */ public static function is_search_area($classname) { return parent::is_search_area($classname); } /** * Fakes the current time for PHPunit. Turns off faking time if called with default parameter. * * Note: This should be replaced with core functionality once possible (see MDL-60644). * * @param float $faketime Current time */ public static function fake_current_time($faketime = 0.0) { static::$phpunitfaketime = $faketime; } /** * Makes build_limitcourseids method public for testing. * * @param \stdClass $formdata Submitted search form data. * * @return array|bool */ public function build_limitcourseids(\stdClass $formdata) { $limitcourseids = parent::build_limitcourseids($formdata); return $limitcourseids; } } tests/fixtures/mock_search_engine.php000064400000010677152265255700014117 0ustar00. namespace mock_search; /** * Search engine for testing purposes. * * @package core_search * @category phpunit * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_search\manager; defined('MOODLE_INTERNAL') || die; class engine extends \core_search\engine { /** @var float If set, waits when adding each document (seconds) */ protected $adddelay = 0; /** @var \core_search\document[] Documents added */ protected $added = []; /** @var array Schema updates applied */ protected $schemaupdates = []; /** @var array delete of course index. */ protected $deletes = []; public function is_installed() { return true; } public function is_server_ready() { return true; } public function add_document($document, $fileindexing = false) { if ($this->adddelay) { \testable_core_search::fake_current_time(manager::get_current_time() + $this->adddelay); } $this->added[] = $document; return true; } public function execute_query($data, $usercontexts, $limit = 0) { // No need to implement. return []; } public function delete($areaid = null) { return null; } public function to_document(\core_search\base $searcharea, $docdata) { return parent::to_document($searcharea, $docdata); } public function get_course($courseid) { return parent::get_course($courseid); } public function get_search_area($areaid) { return parent::get_search_area($areaid); } public function get_query_total_count() { return 0; } /** * Sets an add delay to simulate time taken indexing. * * @param float $seconds Delay in seconds for each document */ public function set_add_delay($seconds) { $this->adddelay = $seconds; } /** * Gets the list of indexed (added) documents since last time this function * was called. * * @return \core_search\document[] List of documents, in order added. */ public function get_and_clear_added_documents() { $added = $this->added; $this->added = []; return $added; } public function update_schema($oldversion, $newversion) { $this->schemaupdates[] = [$oldversion, $newversion]; } /** * Gets all schema updates applied, as an array. Each entry has an array with two values, * old and new version. * * @return array List of schema updates for comparison */ public function get_and_clear_schema_updates() { $result = $this->schemaupdates; $this->schemaupdates = []; return $result; } /** * Records delete of course index so it can be checked later. * * @param int $oldcourseid Course id * @return bool True to indicate action taken */ public function delete_index_for_course(int $oldcourseid) { $this->deletes[] = ['course', $oldcourseid]; return true; } /** * Records delete of context index so it can be checked later. * * @param int $oldcontextid Context id * @return bool True to indicate action taken */ public function delete_index_for_context(int $oldcontextid) { $this->deletes[] = ['context', $oldcontextid]; return true; } /** * Gets all course/context deletes applied, as an array. Each entry is an array with two * values, the first is either 'course' or 'context' and the second is the id deleted. * * @return array List of deletes for comparison */ public function get_and_clear_deletes() { $deletes = $this->deletes; $this->deletes = []; return $deletes; } } tests/fixtures/mock_block_area.php000064400000002355152265255700013401 0ustar00. /** * Test block area. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace block_mockblock\search; defined('MOODLE_INTERNAL') || die; /** * Test block area. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class area extends \core_search\base_block { public function get_document($record, $options = array()) { throw new \coding_exception('Not implemented'); } } tests/document_icon_test.php000064400000003212152265255700012313 0ustar00. namespace core_search; /** * Document icon unit tests. * * @package core_search * @copyright 2018 Dmitrii Metelkin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class document_icon_test extends \advanced_testcase { /** * Test that default component gets returned correctly. */ public function test_default_component(): void { $docicon = new \core_search\document_icon('test_name'); $this->assertEquals('test_name', $docicon->get_name()); $this->assertEquals('moodle', $docicon->get_component()); } /** * Test that name and component get returned correctly. */ public function test_can_get_name_and_component(): void { $docicon = new \core_search\document_icon('test_name', 'test_component'); $this->assertEquals('test_name', $docicon->get_name()); $this->assertEquals('test_component', $docicon->get_component()); } } tests/generator/lib.php000064400000014517152265255700011174 0ustar00. /** * Generator for test search area. * * @package core_search * @category phpunit * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Mock search area data generator class. * * @package core_search * @category test * @copyright 2016 Eric Merrill * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_search_generator extends component_generator_base { /** * Creates the mock search area temp table. */ public function setUp(): void { global $DB; $dbman = $DB->get_manager(); // Make our temp table if we need it. if (!$dbman->table_exists('temp_mock_search_area')) { $table = new \xmldb_table('temp_mock_search_area'); $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); $table->add_field('timemodified', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, null, '0'); $table->add_field('info', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); $dbman->create_temp_table($table); } } /** * Destroys the mock search area temp table. */ public function tearDown(): void { global $DB; $dbman = $DB->get_manager(); // Make our temp table if we need it. if ($dbman->table_exists('temp_mock_search_area')) { $table = new \xmldb_table('temp_mock_search_area'); $dbman->drop_table($table); } } /** * Deletes all records in the search area table. */ public function delete_all() { global $DB; // Delete any records in the search area. $DB->delete_records('temp_mock_search_area'); } /** * Adds a new record to the mock search area based on the provided options. */ public function create_record($options = null) { global $DB, $USER; $record = new \stdClass(); $info = new \stdClass(); if (empty($options->timemodified)) { $record->timemodified = time(); } else { $record->timemodified = $options->timemodified; } if (!isset($options->content)) { $info->content = 'A test message to find.'; } else { $info->content = $options->content; } if (!isset($options->description1)) { $info->description1 = 'Description 1.'; } else { $info->description1 = $options->description1; } if (!isset($options->description2)) { $info->description2 = 'Description 2.'; } else { $info->description2 = $options->description2; } if (!isset($options->title)) { $info->title = 'A basic title'; } else { $info->title = $options->title; } if (!isset($options->contextid)) { $info->contextid = \context_course::instance(SITEID)->id; } else { $info->contextid = $options->contextid; } if (!isset($options->courseid)) { $info->courseid = SITEID; } else { $info->courseid = $options->courseid; } if (!isset($options->userid)) { $info->userid = $USER->id; } else { $info->userid = $options->userid; } if (!isset($options->owneruserid)) { $info->owneruserid = \core_search\manager::NO_OWNER_ID; } else { $info->owneruserid = $options->owneruserid; } // This takes a userid (or array of) that will be denied when check_access() is called. if (!isset($options->denyuserids)) { $info->denyuserids = array(); } else { if (is_array($options->denyuserids)) { $info->denyuserids = $options->denyuserids; } else { $info->denyuserids = array($options->denyuserids); } } // Stored file ids that will be attached when attach_files() is called. if (!isset($options->attachfileids)) { $info->attachfileids = array(); } else { if (is_array($options->attachfileids)) { $info->attachfileids = $options->attachfileids; } else { $info->attachfileids = array($options->attachfileids); } } $record->info = serialize($info); $record->id = $DB->insert_record('temp_mock_search_area', $record); return $record; } /** * Creates a stored file that can be added to mock search area records for indexing. */ public function create_file($options = null) { // Add the searchable file fixture. $syscontext = \context_system::instance(); $filerecord = array( 'contextid' => $syscontext->id, 'component' => 'core', 'filearea' => 'unittest', 'itemid' => 0, 'filepath' => '/', 'filename' => 'searchfile.txt', ); if (isset($options->filename)) { $filerecord['filename'] = $options->filename; } if (isset($options->content)) { $content = $options->content; } else { $content = 'File contents'; } if (isset($options->timemodified)) { $filerecord['timemodified'] = $options->timemodified; } $fs = get_file_storage(); $file = $fs->create_file_from_string($filerecord, $content); return $file; } } tests/document_test.php000064400000025251152265255700011312 0ustar00. namespace core_search; use advanced_testcase; use context_course; use core_mocksearch\search\mock_search_area; use mock_search\engine; use testable_core_search; use stdClass; /** * Unit tests for search document. * * @package core_search * @category test * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\document */ final class document_test extends \advanced_testcase { /** * Setup to ensure that fixtures are loaded. */ public static function setupBeforeClass(): void { global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); } /** * @var Instace of core_search_generator. */ protected $generator = null; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); } /** * Adding this test here as get_areas_user_accesses process is the same, results just depend on the context level. * * @covers ::export_for_template * @return void */ public function test_search_user_accesses(): void { global $PAGE; $area = new mock_search_area(); $renderer = $PAGE->get_renderer('core_search'); $engine = new engine(); $course = $this->getDataGenerator()->create_course(['fullname' => 'Course & Title']); $user = $this->getDataGenerator()->create_user(['firstname' => 'User', 'lastname' => 'Escape & Name']); $this->getDataGenerator()->enrol_user($user->id, $course->id, 'teacher'); $this->setAdminUser(); // Make a record to enter in the search area. $record = new stdClass(); $record->title = 'Escape & Title'; $record->content = 'Escape & Content'; $record->description1 = 'Escape & Description1'; $record->description2 = 'Escape & Description2'; $record->userid = $user->id; $record->courseid = $course->id; $record = $this->generator->create_record($record); // Convert to a 'doc data' type format. $docdata = $area->convert_record_to_doc_array($record); // First see that the docuemnt has the right information, unescaped. $doc = $engine->to_document($area, $docdata); $this->assertEquals('Escape & Title', $doc->get('title')); $this->assertEquals('Escape & Content', $doc->get('content')); $this->assertEquals('Escape & Description1', $doc->get('description1')); $this->assertEquals('Escape & Description2', $doc->get('description2')); $this->assertEquals('User Escape & Name', $doc->get('userfullname')); $this->assertEquals('Course & Title', $doc->get('coursefullname')); // Export for template, and see if it is escaped. $export = $doc->export_for_template($renderer); $this->assertEquals('Escape & Title', $export['title']); $this->assertEquals('Escape & Content', $export['content']); $this->assertEquals('Escape & Description1', $export['description1']); $this->assertEquals('Escape & Description2', $export['description2']); $this->assertEquals('User Escape & Name', $export['userfullname']); $this->assertEquals('Course & Title', $export['coursefullname']); } /** * Test we can set and get document icon. * * @covers ::set_doc_icon */ public function test_get_and_set_doc_icon(): void { $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMockForAbstractClass(); $this->assertNull($document->get_doc_icon()); $docicon = new \core_search\document_icon('test_name', 'test_component'); $document->set_doc_icon($docicon); $this->assertEquals($docicon, $document->get_doc_icon()); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } parent::tearDown(); } /** * Test the document author visibility depending on the user capabilities. * * @covers ::export_for_template * @dataProvider document_author_visibility_provider * @param string $rolename the role name * @param array $capexceptions the capabilities exceptions * @param bool $expected the expected author visibility * @param bool $owndocument if the resulting document belongs to the current user */ public function test_document_author_visibility( string $rolename = 'editingteacher', array $capexceptions = [], bool $expected = true, bool $owndocument = false ): void { global $DB, $PAGE; $area = new mock_search_area(); $renderer = $PAGE->get_renderer('core_search'); $engine = new engine(); $course = $this->getDataGenerator()->create_course(['fullname' => 'Course & Title']); $context = context_course::instance($course->id); $roleid = $DB->get_field('role', 'id', ['shortname' => $rolename]); foreach ($capexceptions as $capability) { assign_capability($capability, CAP_PROHIBIT, $roleid, $context->id); } $user = $this->getDataGenerator()->create_user(['firstname' => 'Test', 'lastname' => 'User']); $this->getDataGenerator()->enrol_user($user->id, $course->id, $rolename); $this->setUser($user); if ($owndocument) { $author = $user; } else { $author = $this->getDataGenerator()->create_user(['firstname' => 'User', 'lastname' => 'Escape & Name']); $this->getDataGenerator()->enrol_user($author->id, $course->id, 'student'); } // Make a record to enter in the search area. $record = new stdClass(); $record->title = 'Escape & Title'; $record->content = 'Escape & Content'; $record->description1 = 'Escape & Description1'; $record->description2 = 'Escape & Description2'; $record->userid = $author->id; $record->courseid = $course->id; $record->contextid = $context->id; $record = $this->generator->create_record($record); // Convert to a 'doc data' type format. $docdata = $area->convert_record_to_doc_array($record); // First see that the document has the user information. $doc = $engine->to_document($area, $docdata); $this->assertEquals(fullname($author), $doc->get('userfullname')); // Export for template, and see if it the user information is exported. $export = $doc->export_for_template($renderer); if ($expected) { $authorname = htmlentities(fullname($author), ENT_COMPAT); $this->assertEquals($authorname, $export['userfullname']); } else { $this->assertArrayNotHasKey('userfullname', $export); } } /** * Data provider for test_document_author_visibility(). * * @return array */ public static function document_author_visibility_provider(): array { return [ 'Teacher' => [ 'rolename' => 'editingteacher', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], 'Non editing teacher' => [ 'rolename' => 'teacher', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], 'Student' => [ 'rolename' => 'student', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], // Adding capability exceptions. 'Student without view profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails'], 'expected' => false, 'owndocument' => false, ], 'Student without view participants' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/course:viewparticipants'], 'expected' => false, 'owndocument' => false, ], 'Student without view participants or profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails', 'moodle/course:viewparticipants'], 'expected' => false, 'owndocument' => false, ], // Users should be able to see its own documents. 'Student author without view profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails'], 'expected' => true, 'owndocument' => true, ], 'Student author without view participants' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/course:viewparticipants'], 'expected' => true, 'owndocument' => true, ], 'Student author without view participants or profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails', 'moodle/course:viewparticipants'], 'expected' => true, 'owndocument' => true, ], ]; } } tests/external/view_results_test.php000064400000004747152265255700014060 0ustar00. namespace core_search\external; use core_external\external_api; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); /** * Tests for the view_results external function. * * @package core_search * @category test * @copyright 2023 Juan Leyva (juan@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\external\view_results */ final class view_results_test extends \externallib_advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * test external api * @covers ::execute * @return void */ public function test_external_view_results(): void { set_config('enableglobalsearch', true); $this->setAdminUser(); // Trigger and capture the event. $sink = $this->redirectEvents(); $result = view_results::execute('forum post', ['title' => 'My progress'], 1); $result = external_api::clean_returnvalue(view_results::execute_returns(), $result); $this->assertEmpty($result['warnings']); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = array_shift($events); $sink->close(); // Checking that the event contains the expected values. $this->assertInstanceOf('core\event\search_results_viewed', $event); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); $this->assertEquals('forum post', $event->get_data()['other']['q']); $this->assertEquals('My progress', $event->get_data()['other']['title']); $this->assertEquals(1, $event->get_data()['other']['page']); } } tests/external/get_search_areas_list_test.php000064400000004335152265255700015630 0ustar00. namespace core_search\external; use core_external\external_api; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); /** * Tests for the get_search_areas_list external function. * * @package core_search * @category test * @copyright 2023 Juan Leyva (juan@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\external\get_search_areas_list */ final class get_search_areas_list_test extends \externallib_advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * test external api * * @covers ::execute * @return void */ public function test_external_get_search_areas_list(): void { set_config('enableglobalsearch', true); set_config('searchenablecategories', true); $this->setAdminUser(); $result = get_search_areas_list::execute(); $result = external_api::clean_returnvalue(get_search_areas_list::execute_returns(), $result); $this->assertNotEmpty($result['areas']); $totalareas = count($result['areas']); // Filter. $result = get_search_areas_list::execute('core-users'); $result = external_api::clean_returnvalue(get_search_areas_list::execute_returns(), $result); $totalfilterareas = count($result['areas']); // Just count numbers, plugins can inject areas. $this->assertLessThan($totalareas, $totalfilterareas); } } tests/external/get_top_results_test.php000064400000007004152265255700014534 0ustar00. namespace core_search\external; use core_external\external_api; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); /** * Tests for the get_top_results external function. * * @package core_search * @category test * @copyright 2023 Juan Leyva (juan@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\external\get_top_results */ final class get_top_results_test extends \externallib_advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * test external api * @covers ::execute * @return void */ public function test_external_get_top_results(): void { set_config('enableglobalsearch', true); set_config('searchenablecategories', true); // Required for top search. set_config('searchmaxtopresults', 5); // Change default. set_config('searchengine', 'simpledb'); $this->setAdminUser(); // Create an index of searchable things. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'SearchTest course']); $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); $forum = $generator->create_module('forum', ['course' => $course->id]); $fgenerator = $generator->get_plugin_generator('mod_forum'); for ($i = 0; $i < 15; $i++) { $fgenerator->create_discussion( [ 'course' => $course->id, 'forum' => $forum->id, 'userid' => $student->id, ] ); } $search = \core_search\manager::instance(); $search->index(); // Test top results. $return = external_api::clean_returnvalue(get_top_results::execute_returns(), get_top_results::execute('discussion', [])); $this->assertCount(5, $return['results']); // We get the 5 top results according to searchmaxtopresults setting value. set_config('searchmaxtopresults', 3); // Change to 3 top. $return = external_api::clean_returnvalue(get_top_results::execute_returns(), get_top_results::execute('discussion', [])); $this->assertCount(3, $return['results']); // Test some filters. $return = external_api::clean_returnvalue(get_top_results::execute_returns(), get_top_results::execute('discussion', ['title' => 'Discussion 11'])); $this->assertCount(1, $return['results']); set_config('searchenablecategories', false); // Disable top search. $return = external_api::clean_returnvalue(get_top_results::execute_returns(), get_top_results::execute('discussion', [])); $this->assertCount(0, $return['results']); } } tests/external/get_results_test.php000064400000011477152265255700013663 0ustar00. namespace core_search\external; use core_external\external_api; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); /** * Tests for the get_results external function. * * @package core_search * @category test * @copyright 2023 Juan Leyva (juan@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\external\get_results */ final class get_results_test extends \externallib_advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * test external api * @covers ::execute * @return void */ public function test_external_get_results(): void { set_config('enableglobalsearch', true); set_config('searchengine', 'simpledb'); $this->setAdminUser(); // Test search not returning anything (nothing in the index yet). $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('one')); $this->assertEquals(0, $return['totalcount']); // Create an index of searchable things. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'SearchTest course']); $anothercourse = $generator->create_course(['fullname' => 'Another']); $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); $studentothercourse = $this->getDataGenerator()->create_and_enrol($anothercourse, 'student'); $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'SearchTest page']); $forum = $generator->create_module('forum', ['course' => $course->id]); $fgenerator = $generator->get_plugin_generator('mod_forum'); for ($i = 0; $i < 15; $i++) { $fgenerator->create_discussion( [ 'course' => $course->id, 'forum' => $forum->id, 'userid' => $student->id, ] ); } $search = \core_search\manager::instance(); $search->index(); // Basic search, by text. $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('page')); $this->assertEquals(1, $return['totalcount']); $this->assertEquals('activity', $return['results'][0]['areaname']); $this->assertEquals($page->name, $return['results'][0]['title']); // Basic search, by name containing text. $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('SearchTest')); $this->assertEquals(2, $return['totalcount']); // Test pagination. $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('discussion', [], 0)); $this->assertCount(10, $return['results']); // The first 10 posts of a total of 15 for the second page. $this->assertEquals(15, $return['totalcount']); $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('discussion', [], 1)); $this->assertCount(5, $return['results']); // The last 5 posts of a total of 15 for the second page. $this->assertEquals(15, $return['totalcount']); // Test some filters. $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('discussion', ['title' => 'Discussion 11'])); $this->assertEquals(1, $return['totalcount']); // No discussions created in the future. $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('discussion', ['timestart' => time() + DAYSECS])); $this->assertEquals(0, $return['totalcount']); // Basic permissions check. $this->setUser($studentothercourse); $return = external_api::clean_returnvalue(get_results::execute_returns(), get_results::execute('discussion', [], 1)); $this->assertCount(0, $return['results']); // I should not see other courses discussions. } } tests/engine_test.php000064400000012420152265255700010733 0ustar00. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class engine_test extends \advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); } /** * Engine basic info. * * @return void */ public function test_engine_info(): void { $engine = new \mock_search\engine(); $this->assertEquals('mock_search', $engine->get_plugin_name()); // Resolves to the default one. $this->assertEquals('\\core_search\\document', $engine->get_document_classname()); } /** * Test engine caches. * * @return void */ public function test_engine_caches(): void { global $DB; $engine = new \mock_search\engine(); $course1 = self::getDataGenerator()->create_course(); $this->assertEquals($course1->id, $engine->get_course($course1->id)->id); $dbreads = $DB->perf_get_reads(); $engine->get_course($course1->id); $this->assertEquals($dbreads, $DB->perf_get_reads()); $fakearea1 = \core_search\manager::generate_areaid('plugintype_unexisting', 'fakearea'); $fakearea2 = \core_search\manager::generate_areaid('mod_unexisting', 'morefake'); $this->assertFalse($engine->get_search_area($fakearea1)); $this->assertFalse($engine->get_search_area($fakearea2)); $this->assertFalse($engine->get_search_area($fakearea2)); $areaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $this->assertInstanceOf('\\mod_forum\\search\\post', $engine->get_search_area($areaid)); $dbreads = $DB->perf_get_reads(); $this->assertInstanceOf('\\mod_forum\\search\\post', $engine->get_search_area($areaid)); $this->assertEquals($dbreads, $DB->perf_get_reads()); } /** * Tests the core functions related to schema updates. */ public function test_engine_schema_modification(): void { // Apply a schema update starting from no version. $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(1, $updates); $this->assertEquals(0, $updates[0][0]); $this->assertEquals(\core_search\document::SCHEMA_VERSION, $updates[0][1]); // Store older version and check that. $engine->record_applied_schema_version(1066101400); $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(1, $updates); $this->assertEquals(1066101400, $updates[0][0]); $this->assertEquals(\core_search\document::SCHEMA_VERSION, $updates[0][1]); // Store current version and check no updates. $engine->record_applied_schema_version(\core_search\document::SCHEMA_VERSION); $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(0, $updates); } /** * Tests the get_supported_orders stub function. */ public function test_get_supported_orders(): void { $engine = new \mock_search\engine(); $orders = $engine->get_supported_orders(\context_system::instance()); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); } /** * Test that search engine sets an icon before render a document. */ public function test_engine_sets_doc_icon(): void { $generator = self::getDataGenerator()->get_plugin_generator('core_search'); $generator->setup(); $area = new \core_mocksearch\search\mock_search_area(); $engine = new \mock_search\engine(); $record = $generator->create_record(); $docdata = $area->get_document($record)->export_for_engine(); $doc = $engine->to_document($area, $docdata); $this->assertNotNull($doc->get_doc_icon()); $generator->teardown(); } } tests/base_test.php000064400000013660152265255700010407 0ustar00. namespace core_search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @copyright 2017 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class base_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } parent::tearDown(); } /** * Test base get search fileareas */ public function test_get_search_fileareas_base(): void { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $result = $stub->get_search_fileareas(); $this->assertEquals(array(), $result); } /** * Test base attach files */ public function test_attach_files_base(): void { $filearea = 'search'; $component = 'mod_test'; // Create file to add. $fs = get_file_storage(); $filerecord = array( 'contextid' => 1, 'component' => $component, 'filearea' => $filearea, 'itemid' => 1, 'filepath' => '/', 'filename' => 'testfile.txt'); $content = 'All the news that\'s fit to print'; $file = $fs->create_file_from_string($filerecord, $content); // Construct the search document. $rec = new \stdClass(); $rec->contextid = 1; $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record($rec); $document = $area->get_document($record); // Create a mock from the abstract class, // with required methods stubbed. $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_search_fileareas', 'get_component_name')); $stub = $builder->getMockForAbstractClass(); $stub->method('get_search_fileareas')->willReturn(array($filearea)); $stub->method('get_component_name')->willReturn($component); // Attach file to our test document. $stub->attach_files($document); // Verify file is attached. $files = $document->get_files(); $file = array_values($files)[0]; $this->assertEquals(1, count($files)); $this->assertEquals($content, $file->get_content()); } /** * Tests the base version (stub) of get_contexts_to_reindex. */ public function test_get_contexts_to_reindex(): void { $area = new \core_mocksearch\search\mock_search_area(); $this->assertEquals([\context_system::instance()], iterator_to_array($area->get_contexts_to_reindex(), false)); } /** * Test default document icon. */ public function test_get_default_doc_icon(): void { $basearea = $this->getMockBuilder('\core_search\base') ->disableOriginalConstructor() ->getMockForAbstractClass(); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $basearea->get_doc_icon($document); $this->assertEquals('i/empty', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } /** * Test base search area category names. */ public function test_get_category_names(): void { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $expected = ['core-other']; $this->assertEquals($expected, $stub->get_category_names()); } /** * Test getting all required search area setting names. */ public function test_get_settingnames(): void { $expected = array('_enabled', '_indexingstart', '_indexingend', '_lastindexrun', '_docsignored', '_docsprocessed', '_recordsprocessed', '_partial'); $this->assertEquals($expected, \core_search\base::get_settingnames()); } } tests/base_activity_test.php000064400000040107152265255700012317 0ustar00. namespace core_search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @copyright 2017 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class base_activity_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var \core_search_generator Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; /** @var \context[] Array of test contexts */ protected $contexts; /** @var \stdClass[] Array of test forum objects */ protected $forums; public function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->setAdminUser(); // Create course and 2 forums. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $this->contexts['c1'] = \context_course::instance($course->id); $this->forums[1] = $generator->create_module('forum', ['course' => $course->id, 'name' => 'Forum 1', 'intro' => '

Intro 1

', 'introformat' => FORMAT_HTML]); $this->contexts['f1'] = \context_module::instance($this->forums[1]->cmid); $this->forums[2] = $generator->create_module('forum', ['course' => $course->id, 'name' => 'Forum 2', 'intro' => '

Intro 2

', 'introformat' => FORMAT_HTML]); $this->contexts['f2'] = \context_module::instance($this->forums[2]->cmid); // Create another 2 courses (in same category and in a new category) with one forum each. $this->contexts['cc1'] = \context_coursecat::instance($course->category); $course2 = $generator->create_course(); $this->contexts['c2'] = \context_course::instance($course2->id); $this->forums[3] = $generator->create_module('forum', ['course' => $course2->id, 'name' => 'Forum 3', 'intro' => '

Intro 3

', 'introformat' => FORMAT_HTML]); $this->contexts['f3'] = \context_module::instance($this->forums[3]->cmid); $cat2 = $generator->create_category(); $this->contexts['cc2'] = \context_coursecat::instance($cat2->id); $course3 = $generator->create_course(['category' => $cat2->id]); $this->contexts['c3'] = \context_course::instance($course3->id); $this->forums[4] = $generator->create_module('forum', ['course' => $course3->id, 'name' => 'Forum 4', 'intro' => '

Intro 4

', 'introformat' => FORMAT_HTML]); $this->contexts['f4'] = \context_module::instance($this->forums[4]->cmid); // Hack about with the time modified values. foreach ($this->forums as $index => $forum) { $DB->set_field('forum', 'timemodified', $index, ['id' => $forum->id]); } } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } parent::tearDown(); } /** * Test base activity get search fileareas */ public function test_get_search_fileareas_base(): void { $builder = $this->getMockBuilder('\core_search\base_activity'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $result = $stub->get_search_fileareas(); $this->assertEquals(array('intro'), $result); } /** * Test base attach files */ public function test_attach_files_base(): void { $filearea = 'intro'; $component = 'mod_forum'; $module = 'forum'; $course = self::getDataGenerator()->create_course(); $activity = self::getDataGenerator()->create_module('forum', array('course' => $course->id)); $context = \context_module::instance($activity->cmid); $contextid = $context->id; // Create file to add. $fs = get_file_storage(); $filerecord = array( 'contextid' => $contextid, 'component' => $component, 'filearea' => $filearea, 'itemid' => 0, 'filepath' => '/', 'filename' => 'testfile.txt'); $content = 'All the news that\'s fit to print'; $file = $fs->create_file_from_string($filerecord, $content); // Construct the search document. $rec = new \stdClass(); $rec->courseid = $course->id; $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record($rec); $document = $area->get_document($record); $document->set('itemid', $activity->id); // Create a mock from the abstract class, // with required methods stubbed. $builder = $this->getMockBuilder('\core_search\base_activity'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_module_name', 'get_component_name')); $stub = $builder->getMockForAbstractClass(); $stub->method('get_module_name')->willReturn($module); $stub->method('get_component_name')->willReturn($component); // Attach file to our test document. $stub->attach_files($document); // Verify file is attached. $files = $document->get_files(); $file = array_values($files)[0]; $this->assertEquals(1, count($files)); $this->assertEquals($content, $file->get_content()); } /** * Tests getting the recordset. */ public function test_get_document_recordset(): void { global $USER, $DB; // Get all the forums to index (no restriction). $area = new \mod_forum\search\activity(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); // Should return all forums. $this->assertCount(4, $results); // Each result should basically have the contents of the forum table. We'll just check // the key fields for the first one and then the other ones by id only. $this->assertEquals($this->forums[1]->id, $results[0]->id); $this->assertEquals(1, $results[0]->timemodified); $this->assertEquals($this->forums[1]->course, $results[0]->course); $this->assertEquals('Forum 1', $results[0]->name); $this->assertEquals('

Intro 1

', $results[0]->intro); $this->assertEquals(FORMAT_HTML, $results[0]->introformat); $allids = self::records_to_ids($this->forums); $this->assertEquals($allids, self::records_to_ids($results)); // Repeat with a time restriction. $results = self::recordset_to_indexed_array($area->get_document_recordset(3)); $this->assertEquals([$this->forums[3]->id, $this->forums[4]->id], self::records_to_ids($results)); // Now use context restrictions. First, the whole site (no change). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, \context_system::instance())); $this->assertEquals($allids, self::records_to_ids($results)); // Course 1 only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['c1'])); $this->assertEquals([$this->forums[1]->id, $this->forums[2]->id], self::records_to_ids($results)); // Course 2 only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['c2'])); $this->assertEquals([$this->forums[3]->id], self::records_to_ids($results)); // Specific forum only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['f4'])); $this->assertEquals([$this->forums[4]->id], self::records_to_ids($results)); // Category 1 context (courses 1 and 2). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['cc1'])); $this->assertEquals([$this->forums[1]->id, $this->forums[2]->id, $this->forums[3]->id], self::records_to_ids($results)); // Category 2 context (course 3). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['cc2'])); $this->assertEquals([$this->forums[4]->id], self::records_to_ids($results)); // Combine context restriction (category 1) with timemodified. $results = self::recordset_to_indexed_array($area->get_document_recordset( 2, $this->contexts['cc1'])); $this->assertEquals([$this->forums[2]->id, $this->forums[3]->id], self::records_to_ids($results)); // Find an arbitrary block on the system to get a block context. $blockid = array_values($DB->get_records('block_instances', null, 'id', 'id', 0, 1))[0]->id; $blockcontext = \context_block::instance($blockid); // Block context (cannot return anything, so always null). $this->assertNull($area->get_document_recordset(0, $blockcontext)); // User context (cannot return anything, so always null). $usercontext = \context_user::instance($USER->id); $this->assertNull($area->get_document_recordset(0, $usercontext)); } /** * Utility function to convert recordset to array for testing. * * @param \moodle_recordset $rs Recordset to convert * @return array Array indexed by number (0, 1, 2, ...) */ protected static function recordset_to_indexed_array(\moodle_recordset $rs) { $results = []; foreach ($rs as $rec) { $results[] = $rec; } $rs->close(); return $results; } /** * Utility function to convert records to array of IDs. * * @param array $recs Records which should have an 'id' field * @return array Array of ids */ protected static function records_to_ids(array $recs) { $ids = []; foreach ($recs as $rec) { $ids[] = $rec->id; } return $ids; } /** * Tests the get_doc_url function. */ public function test_get_doc_url(): void { $area = new \mod_forum\search\activity(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); for ($i = 0; $i < 4; $i++) { $this->assertEquals(new \moodle_url('/mod/forum/view.php', ['id' => $this->forums[$i + 1]->cmid]), $area->get_doc_url($area->get_document($results[$i]))); } } /** * Tests the check_access function. */ public function test_check_access(): void { global $CFG; require_once($CFG->dirroot . '/course/lib.php'); // Create a test user who can access courses 1 and 2 (everything except forum 4). $generator = $this->getDataGenerator(); $user = $generator->create_user(); $generator->enrol_user($user->id, $this->forums[1]->course, 'student'); $generator->enrol_user($user->id, $this->forums[3]->course, 'student'); $this->setUser($user); // Delete forum 2 and set forum 3 hidden. course_delete_module($this->forums[2]->cmid); set_coursemodule_visible($this->forums[3]->cmid, 0); // Call check access on all the first three. $area = new \mod_forum\search\activity(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access( $this->forums[1]->id)); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access( $this->forums[2]->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $area->check_access( $this->forums[3]->id)); // Note: Do not check forum 4 which is in a course the user can't access; this will return // ACCESS_GRANTED, but it does not matter because the search engine will not have included // that context in the list to search. (This is because the $cm->uservisible access flag // is only valid if the user is known to be able to access the course.) } /** * Tests the module version of get_contexts_to_reindex, which is supposed to return all the * activity contexts in order of date added. */ public function test_get_contexts_to_reindex(): void { global $DB; $this->resetAfterTest(); // Set up a course with two URLs and a Page. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'TCourse']); $url1 = $generator->create_module('url', ['course' => $course->id, 'name' => 'TURL1']); $url2 = $generator->create_module('url', ['course' => $course->id, 'name' => 'TURL2']); $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'TPage1']); // Hack the items so they have different added times. $now = time(); $DB->set_field('course_modules', 'added', $now - 3, ['id' => $url2->cmid]); $DB->set_field('course_modules', 'added', $now - 2, ['id' => $url1->cmid]); $DB->set_field('course_modules', 'added', $now - 1, ['id' => $page->cmid]); // Check the URL contexts are in date order. $urlarea = new \mod_url\search\activity(); $contexts = iterator_to_array($urlarea->get_contexts_to_reindex(), false); $this->assertEquals([\context_module::instance($url1->cmid), \context_module::instance($url2->cmid)], $contexts); // Check the Page contexts. $pagearea = new \mod_page\search\activity(); $contexts = iterator_to_array($pagearea->get_contexts_to_reindex(), false); $this->assertEquals([\context_module::instance($page->cmid)], $contexts); // Check another module area that has no instances. $glossaryarea = new \mod_glossary\search\activity(); $contexts = iterator_to_array($glossaryarea->get_contexts_to_reindex(), false); $this->assertEquals([], $contexts); } /** * Test document icon. */ public function test_get_doc_icon(): void { $baseactivity = $this->getMockBuilder('\core_search\base_activity') ->disableOriginalConstructor() ->onlyMethods(array('get_module_name')) ->getMockForAbstractClass(); $baseactivity->method('get_module_name')->willReturn('test_activity'); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $baseactivity->get_doc_icon($document); $this->assertEquals('monologo', $result->get_name()); $this->assertEquals('test_activity', $result->get_component()); } } tests/base_block_test.php000064400000050062152265255700011556 0ustar00. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_block_area.php'); /** * Unit tests for the base_block class. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class base_block_test extends \advanced_testcase { /** * Tests getting the name out of the class name. */ public function test_get_block_name(): void { $area = new \block_mockblock\search\area(); $this->assertEquals('mockblock', $area->get_block_name()); } /** * Tests getting the recordset. */ public function test_get_document_recordset(): void { global $DB, $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create another 2 courses (in same category and in a new category). $cat1context = \context_coursecat::instance($course->category); $course2 = $generator->create_course(); $course2context = \context_course::instance($course2->id); $cat2 = $generator->create_category(); $cat2context = \context_coursecat::instance($cat2->id); $course3 = $generator->create_course(['category' => $cat2->id]); $course3context = \context_course::instance($course3->id); // Add blocks by hacking table (because it's not a real block type). // 1. Block on course page. $configdata = base64_encode(serialize((object) ['example' => 'content'])); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $block1id = $DB->insert_record('block_instances', $instance); $block1context = \context_block::instance($block1id); // 2. Block on activity page. $instance->parentcontextid = $pagecontext->id; $instance->pagetypepattern = 'mod-page-view'; $instance->timemodified = 2; $block2id = $DB->insert_record('block_instances', $instance); \context_block::instance($block2id); // 3. Block on site context. $sitecourse = get_site(); $sitecontext = \context_course::instance($sitecourse->id); $instance->parentcontextid = $sitecontext->id; $instance->pagetypepattern = 'site-index'; $instance->timemodified = 3; $block3id = $DB->insert_record('block_instances', $instance); $block3context = \context_block::instance($block3id); // 4. Block on course page but no data. $instance->parentcontextid = $coursecontext->id; $instance->pagetypepattern = 'course-view-*'; unset($instance->configdata); $instance->timemodified = 4; $block4id = $DB->insert_record('block_instances', $instance); \context_block::instance($block4id); // 5. Block on course page but not this block. $instance->blockname = 'mockotherblock'; $instance->configdata = $configdata; $instance->timemodified = 5; $block5id = $DB->insert_record('block_instances', $instance); \context_block::instance($block5id); // 6. Block on course page with '*' page type. $instance->blockname = 'mockblock'; $instance->pagetypepattern = '*'; $instance->timemodified = 6; $block6id = $DB->insert_record('block_instances', $instance); \context_block::instance($block6id); // 7. Block on course page with 'course-*' page type. $instance->pagetypepattern = 'course-*'; $instance->timemodified = 7; $block7id = $DB->insert_record('block_instances', $instance); \context_block::instance($block7id); // 8. Block on course 2. $instance->parentcontextid = $course2context->id; $instance->timemodified = 8; $block8id = $DB->insert_record('block_instances', $instance); \context_block::instance($block8id); // 9. Block on course 3. $instance->parentcontextid = $course3context->id; $instance->timemodified = 9; $block9id = $DB->insert_record('block_instances', $instance); \context_block::instance($block9id); // Get all the blocks. $area = new \block_mockblock\search\area(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); // Only blocks 1, 3, 6, 7, 8, 9 should be returned. Check all the fields for the first two. $this->assertCount(6, $results); $this->assertEquals($block1id, $results[0]->id); $this->assertEquals(1, $results[0]->timemodified); $this->assertEquals(1, $results[0]->timecreated); $this->assertEquals($configdata, $results[0]->configdata); $this->assertEquals($course->id, $results[0]->courseid); $this->assertEquals($block1context->id, $results[0]->contextid); $this->assertEquals($block3id, $results[1]->id); $this->assertEquals(3, $results[1]->timemodified); $this->assertEquals(1, $results[1]->timecreated); $this->assertEquals($configdata, $results[1]->configdata); $this->assertEquals($sitecourse->id, $results[1]->courseid); $this->assertEquals($block3context->id, $results[1]->contextid); // For the later ones, just check it got the right ones! $this->assertEquals($block6id, $results[2]->id); $this->assertEquals($block7id, $results[3]->id); $this->assertEquals($block8id, $results[4]->id); $this->assertEquals($block9id, $results[5]->id); // Repeat with a time restriction. $results = self::recordset_to_indexed_array($area->get_document_recordset(2)); // Only block 3, 6, 7, 8, and 9 are returned. $this->assertEquals([$block3id, $block6id, $block7id, $block8id, $block9id], self::records_to_ids($results)); // Now use context restrictions. First, the whole site (no change). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, \context_system::instance())); $this->assertEquals([$block1id, $block3id, $block6id, $block7id, $block8id, $block9id], self::records_to_ids($results)); // Course page only (leave out the one on site page and other courses). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $coursecontext)); $this->assertEquals([$block1id, $block6id, $block7id], self::records_to_ids($results)); // Other course page only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $course2context)); $this->assertEquals([$block8id], self::records_to_ids($results)); // Activity module only (no results). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $pagecontext)); $this->assertCount(0, $results); // Specific block context. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $block3context)); $this->assertEquals([$block3id], self::records_to_ids($results)); // User context (no results). $usercontext = \context_user::instance($USER->id); $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $usercontext)); $this->assertCount(0, $results); // Category 1 context (courses 1 and 2). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $cat1context)); $this->assertEquals([$block1id, $block6id, $block7id, $block8id], self::records_to_ids($results)); // Category 2 context (course 3). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $cat2context)); $this->assertEquals([$block9id], self::records_to_ids($results)); // Combine context restriction (category 1) with timemodified. $results = self::recordset_to_indexed_array($area->get_document_recordset( 7, $cat1context)); $this->assertEquals([$block7id, $block8id], self::records_to_ids($results)); } /** * Utility function to convert recordset to array for testing. * * @param \moodle_recordset $rs Recordset to convert * @return array Array indexed by number (0, 1, 2, ...) */ protected static function recordset_to_indexed_array(\moodle_recordset $rs) { $results = []; foreach ($rs as $rec) { $results[] = $rec; } $rs->close(); return $results; } /** * Utility function to convert records to array of IDs. * * @param array $recs Records which should have an 'id' field * @return array Array of ids */ protected static function records_to_ids(array $recs) { $ids = []; foreach ($recs as $rec) { $ids[] = $rec->id; } return $ids; } /** * Tests the get_doc_url function. */ public function test_get_doc_url(): void { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create block on course page. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $blockid = $DB->insert_record('block_instances', $instance); // Get document URL. $area = new \block_mockblock\search\area(); $doc = $this->get_doc($course->id, $blockid); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $blockid); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); // Repeat with block on site page. $sitecourse = get_site(); $sitecontext = \context_course::instance($sitecourse->id); $instance->pagetypepattern = 'site-index'; $instance->parentcontextid = $sitecontext->id; $block2id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc2 = $this->get_doc($course->id, $block2id); $expected = new \moodle_url('/', ['redirect' => 0], 'inst' . $block2id); $this->assertEquals($expected, $area->get_doc_url($doc2)); $this->assertEquals($expected, $area->get_context_url($doc2)); // Repeat with block on module page (this cannot happen yet because the search query will // only include course context blocks, but let's check it works for the future). $instance->pagetypepattern = 'mod-page-view'; $instance->parentcontextid = $pagecontext->id; $block3id = $DB->insert_record('block_instances', $instance); // Get and check document URL, ignoring debugging message for unsupported page type. $debugmessage = 'Unexpected module-level page type for block ' . $block3id . ': mod-page-view'; $doc3 = $this->get_doc($course->id, $block3id); $this->assertDebuggingCalledCount(2, [$debugmessage, $debugmessage]); $expected = new \moodle_url('/mod/page/view.php', ['id' => $page->cmid], 'inst' . $block3id); $this->assertEquals($expected, $area->get_doc_url($doc3)); $this->assertDebuggingCalled($debugmessage); $this->assertEquals($expected, $area->get_context_url($doc3)); $this->assertDebuggingCalled($debugmessage); // Repeat with another block on course page but '*' pages. $instance->pagetypepattern = '*'; $instance->parentcontextid = $coursecontext->id; $block4id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc = $this->get_doc($course->id, $block4id); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $block4id); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); // And same thing but 'course-*' pages. $instance->pagetypepattern = 'course-*'; $block5id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc = $this->get_doc($course->id, $block5id); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $block5id); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); } /** * Tests the check_access function. */ public function test_check_access(): void { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create block on course page. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $blockid = $DB->insert_record('block_instances', $instance); // Check access for block that exists. $area = new \block_mockblock\search\area(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); // Check access for nonexistent block. $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid + 1)); // Check if block is not in a course context any longer. $DB->set_field('block_instances', 'parentcontextid', $pagecontext->id, ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid)); // Or what if it is in a course context but has supported vs. unsupported page type. $DB->set_field('block_instances', 'parentcontextid', $coursecontext->id, ['id' => $blockid]); $DB->set_field('block_instances', 'pagetypepattern', 'course-*', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', '*', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', 'course-view-frogs', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', 'anythingelse', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid)); } /** * Tests the block version of get_contexts_to_reindex, which is supposed to return all the * block contexts in order of date added. */ public function test_get_contexts_to_reindex(): void { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create blocks on course page, with time modified non-sequential. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 100, 'configdata' => $configdata]; $blockid1 = $DB->insert_record('block_instances', $instance); $context1 = \context_block::instance($blockid1); $instance->timemodified = 120; $blockid2 = $DB->insert_record('block_instances', $instance); $context2 = \context_block::instance($blockid2); $instance->timemodified = 110; $blockid3 = $DB->insert_record('block_instances', $instance); $context3 = \context_block::instance($blockid3); // Create another block on the activity page (not included). $instance->parentcontextid = $pagecontext->id; $blockid4 = $DB->insert_record('block_instances', $instance); \context_block::instance($blockid4); // Check list of contexts. $area = new \block_mockblock\search\area(); $contexts = iterator_to_array($area->get_contexts_to_reindex(), false); $expected = [ $context2, $context3, $context1 ]; $this->assertEquals($expected, $contexts); } /** * Gets a search document object from the fake search area. * * @param int $courseid Course id in document * @param int $blockinstanceid Block instance id in document * @return \core_search\document Document object */ protected function get_doc($courseid, $blockinstanceid) { $engine = \testable_core_search::instance()->get_engine(); $area = new \block_mockblock\search\area(); $docdata = ['id' => $blockinstanceid, 'courseid' => $courseid, 'areaid' => $area->get_area_id(), 'itemid' => 0]; return $engine->to_document($area, $docdata); } /** * Test document icon. */ public function test_get_doc_icon(): void { $baseblock = $this->getMockBuilder('\core_search\base_block') ->disableOriginalConstructor() ->getMockForAbstractClass(); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $baseblock->get_doc_icon($document); $this->assertEquals('e/anchor', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } } tests/skip_future_documents_iterator_test.php000064400000015567152265255700016037 0ustar00. /** * Test iterator that skips future documents * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Test iterator that skips future documents * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class skip_future_documents_iterator_test extends \basic_testcase { /** * Test normal case with all documents in the past. */ public function test_iterator_all_in_past(): void { $past = strtotime('2017-11-01'); $documents = [ self::make_doc($past, 1), self::make_doc($past + 1, 2), self::make_doc($past + 2, 3) ]; $this->assertEquals('mod_x-frog-1.mod_x-frog-2.mod_x-frog-3.', self::do_iterator($documents)); } /** * Confirm that the iterator does not call its parent iterator current() function too many * times. */ public function test_iterator_performance(): void { $counter = new test_counting_iterator(); $iterator = new skip_future_documents_iterator($counter); $items = 0; foreach ($iterator as $value) { $this->assertEquals(false, $value); $items++; } $this->assertEquals(3, $items); $this->assertEquals(3, $counter->get_count()); } /** * Test with no documents at all. */ public function test_iterator_empty(): void { $this->assertEquals('', self::do_iterator([])); } /** * Test if some documents are in the future. */ public function test_iterator_some_in_future(): void { $past = strtotime('2017-11-01'); $future = time() + 1000; $documents = [ self::make_doc($past, 1), self::make_doc($past + 1, 2), self::make_doc($future, 3) ]; $this->assertEquals('mod_x-frog-1.mod_x-frog-2.', self::do_iterator($documents)); } /** * Test if all documents are in the future. */ public function test_iterator_all_in_future(): void { $future = time() + 1000; $documents = [ self::make_doc($future, 1), self::make_doc($future + 1, 2), self::make_doc($future + 2, 3) ]; $this->assertEquals('', self::do_iterator($documents)); } /** * Test when some documents return error. */ public function test_iterator_some_false(): void { $past = strtotime('2017-11-01'); $documents = [ self::make_doc($past, 1), false, self::make_doc($past + 2, 3) ]; $this->assertEquals('mod_x-frog-1.false.mod_x-frog-3.', self::do_iterator($documents)); } /** * Test when all documents return error. */ public function test_iterator_all_false(): void { $documents = [ false, false, false ]; $this->assertEquals('false.false.false.', self::do_iterator($documents)); } /** * Test iterator with all cases. */ public function test_iterator_past_false_and_future(): void { $past = strtotime('2017-11-01'); $future = time() + 1000; $documents = [ false, self::make_doc($past, 1), false, self::make_doc($past + 1, 2), false, self::make_doc($future, 3), false ]; $this->assertEquals('false.mod_x-frog-1.false.mod_x-frog-2.false.', self::do_iterator($documents)); } /** * Helper function to create a search document. * * @param int $time Modified time * @param int $index Item id * @return document Search document */ protected static function make_doc($time, $index) { $doc = new document($index, 'mod_x', 'frog'); $doc->set('modified', $time); return $doc; } /** * Puts documents through the iterator and returns result as a string for easy testing. * * @param document[] $documents Array of documents * @return string Documents converted to string */ protected static function do_iterator(array $documents) { $parent = new \ArrayIterator($documents); $iterator = new skip_future_documents_iterator($parent); $result = ''; foreach ($iterator as $rec) { if (!$rec) { $result .= 'false.'; } else { $result .= $rec->get('id') . '.'; } } return $result; } } /** * Fake iterator just for counting how many times current() is called. It returns 'false' 3 times. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_counting_iterator implements \Iterator { /** @var int Current position in iterator */ protected $pos = 0; /** @var int Number of calls to current() function */ protected $count = 0; /** * Returns the current element. * * @return mixed Can return any type. */ #[\ReturnTypeWillChange] public function current() { $this->count++; return false; } /** * Counts iterator usage. * * @return int Number of times current() was called */ public function get_count() { return $this->count; } /** * Goes on to the next element. */ public function next(): void { $this->pos++; } /** * Gets the key (not supported) * * @throws \coding_exception Always */ #[\ReturnTypeWillChange] public function key() { throw new \coding_exception('Unsupported'); } /** * Checks if iterato is valid (still has entries). * * @return bool True if still valid */ public function valid(): bool { return $this->pos < 3; } /** * Rewinds the iterator. */ public function rewind(): void { $this->pos = 0; } } tests/event/events_test.php000064400000005236152265255700012122 0ustar00. /** * Unit tests for search events. * * @package core_search * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\event; /** * Unit tests for search events. * * @package core_search * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class events_test extends \advanced_testcase { /** * test_search_results_viewed * * @return void */ public function test_search_results_viewed(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $sink = $this->redirectEvents(); // Basic event. \core_search\manager::trigger_search_results_viewed([ 'q' => 'I am a query', 'page' => 0, ]); $events = $sink->get_events(); $event = reset($events); $sink->clear(); $this->assertEquals(\context_system::instance(), $event->get_context()); $urlparams = ['q' => 'I am a query', 'page' => 0]; $this->assertEquals($urlparams, $event->get_url()->params()); \core_search\manager::trigger_search_results_viewed([ 'q' => 'I am a query', 'page' => 2, 'title' => 'I am the title', 'areaids' => array(3,4,5), 'courseids' => array(2,3), 'timestart' => 1445644800, 'timeend' => 1477267200 ]); $events = $sink->get_events(); $event = reset($events); $this->assertEquals(\context_system::instance(), $event->get_context()); $urlparams = ['q' => 'I am a query', 'page' => 2, 'title' => 'I am the title', 'timestart' => 1445644800, 'timeend' => 1477267200]; $this->assertEquals($urlparams, $event->get_url()->params()); } } tests/external_test.php000064400000006017152265255700011315 0ustar00. /** * External function unit tests. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; /** * External function unit tests. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class external_test extends \advanced_testcase { public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * Checks the get_relevant_users function used when selecting users in search filter. */ public function test_get_relevant_users(): void { // Set up two users to search for and one to do the searching. $generator = $this->getDataGenerator(); $student1 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Aardvark']); $student2 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Beetle']); $student3 = $generator->create_user(['firstname' => 'Zebedee', 'lastname' => 'Boing']); $course = $generator->create_course(); $generator->enrol_user($student1->id, $course->id, 'student'); $generator->enrol_user($student2->id, $course->id, 'student'); $generator->enrol_user($student3->id, $course->id, 'student'); // As student 3, search for the other two. $this->setUser($student3); $result = external::clean_returnvalue( external::get_relevant_users_returns(), external::get_relevant_users('Amelia', 0) ); // Check we got the two expected users back. $this->assertEquals([ $student1->id, $student2->id, ], array_column($result, 'id')); // Check that the result contains all the expected fields. $this->assertEquals($student1->id, $result[0]['id']); $this->assertEquals('Amelia Aardvark', $result[0]['fullname']); $this->assertStringContainsString('/u/f2', $result[0]['profileimageurlsmall']); // Check we aren't leaking information about user email address (for instance). $this->assertArrayNotHasKey('email', $result[0]); // Note: We are not checking search permissions, search by different fields, etc. as these // are covered by the core_user::search unit test. } } tests/behat/search_query.feature000064400000013242152265255700013053 0ustar00@core @core_search Feature: Use global search interface In order to search for things As a user I need to be able to type search queries and see results Background: Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | simpledb | And the following "courses" exist: | shortname | fullname | | F1 | Amphibians | And the following "activities" exist: | activity | name | intro | course | idnumber | | page | PageName1 frogs amphibians | PageDesc1 | F1 | PAGE1 | | forum | ForumName1 toads amphibians | ForumDesc1 | F1 | FORUM1 | And I update the global search index And I log in as "admin" @javascript Scenario: Search from header search box with one result When I search for "frogs" using the header global search box Then I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "PageName1" And I should see "PageName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search from search page with two results When I search for "zombies" using the header global search box Then I should see "No results" And I set the field "id_q" to "amphibians" # You cannot press "Search" because there's a fieldset with the same name that gets in the way. And I press "id_submitbutton" And I should see "ForumName1" And I should see "ForumDesc1" And I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "ForumName1" And I should see "ForumName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search from search page with quotes Given I search for "zombies" using the header global search box And I should see "No results" When I set the field "id_q" to "\"amphibians\"" # You cannot press "Search" because there's a fieldset with the same name that gets in the way. And I press "id_submitbutton" Then I should see "ForumName1" And I should see "ForumDesc1" And I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "ForumName1" And I should see "ForumName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search starting from site context (no within option) When I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "Search within" And I should see "Courses" in the "region-main" "region" @javascript Scenario: Search starting from course context (within option lists course) When I am on "Amphibians" course homepage And I search for "frogs" using the header global search box And I expand all fieldsets Then I should see "Search within" And I select "Everywhere you can access" from the "Search within" singleselect And I should see "Courses" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" @javascript Scenario: Search starting from forum context (within option lists course and forum) Given I am on the "ForumName1 toads amphibians" "Forum activity" page When I search for "frogs" using the header global search box Then I expand all fieldsets And I should see "Search within" And I select "Everywhere you can access" from the "Search within" singleselect And I should see "Courses" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" And I select "Forum: ForumName1" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" @javascript Scenario: Check that groups option in search form appears when intended # Switch to mocked Solr search because simpledb doesn't support groups. Given solr is installed And the following config values are set as admin: | searchengine | solr | And the following "groups" exist: | name | course | idnumber | | A Group | F1 | G1 | | B Group | F1 | G2 | And the following "activities" exist: | activity | name | course | idnumber | groupmode | | forum | ForumSG | F1 | FORUM2 | 1 | When I am on the ForumSG "Forum activity" page And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "All groups" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should see "All groups" in the "region-main" "region" And I set the field "Groups" to "A Group" And I select "Forum: ForumSG" from the "Search within" singleselect And I should see "A Group" in the "region-main" "region" And I am on the "ForumName1 toads amphibians" "Forum activity" page And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "All groups" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should see "All groups" in the "region-main" "region" And I select "Forum: ForumName1" from the "Search within" singleselect And I should not see "All groups" in the "region-main" "region" tests/behat/search_by_user.feature000064400000005476152265255700013370 0ustar00@core @core_search Feature: Select users when searching for user-created content In order to search for content by specific users As a user I need to be able to add users to the select list in the search form Background: Given solr is installed And the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | solr | And the following "courses" exist: | shortname | fullname | | C1 | Frogs | | C2 | Zombies | And the following "activities" exist: | activity | name | intro | course | idnumber | | page | PageName1 | PageDesc1 | C1 | PAGE1 | And the following "users" exist: | username | firstname | lastname | | s1 | Anne | Other | | s2 | Anne | Additional | | t | Anne | Ditin | And the following "course enrolments" exist: | user | course | role | | s1 | C1 | student | | s2 | C2 | student | | t | C1 | teacher | @javascript Scenario: As administrator, search for users from home page Given I log in as "admin" And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets When I expand the "Users" autocomplete # Alphabetical last name order. Then "Anne Additional" "text" should appear before "Anne Ditin" "text" in the "Users" "autocomplete" And "Anne Ditin" "text" should appear before "Anne Other" "text" in the "Users" "autocomplete" @javascript Scenario: As administrator, search for users within course Given I log in as "admin" And I am on "Frogs" course homepage And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets And I select "Course: Frogs" from the "Search within" singleselect When I expand the "Users" autocomplete # Users in selected course appear first. Then "Anne Additional" "text" should appear after "Anne Other" "text" in the "Users" "autocomplete" @javascript Scenario: As student, cannot see users on other courses Given I log in as "s1" And I am on "Frogs" course homepage And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets When I expand the "Users" autocomplete Then "Anne Ditin" "text" should appear before "Anne Other" "text" in the "Users" "autocomplete" And "Anne Additional" "text" should not exist tests/behat/search_information.feature000064400000003352152265255700014234 0ustar00@core @core_search Feature: Show system information in the search interface In order to let users know if there are current problems with search As an admin I need to be able to show information on search pages Background: Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | simpledb | And I log in as "admin" @javascript Scenario: Information displays when enabled When the following config values are set as admin: | searchbannerenable | 1 | | searchbanner | The search currently only finds frog-related content; we hope to fix it soon. | And I search for "toads" using the header global search box Then I should see "The search currently only finds frog-related content" in the ".notifywarning" "css_element" @javascript Scenario: Information does not display when not enabled When the following config values are set as admin: | searchbannerenable | 0 | | searchbanner | The search currently only finds frog-related content; we hope to fix it soon. | And I search for "toads" using the header global search box Then I should not see "The search currently only finds frog-related content" And ".notifywarning" "css_element" should not exist @javascript Scenario: Information does not display when left blank When the following config values are set as admin: | searchbannerenable | 1 | | searchbanner | | And I search for "toads" using the header global search box Then ".notifywarning" "css_element" should not exist tests/behat/setup_search_engine.feature000064400000002010152265255700014362 0ustar00@core @core_search Feature: Plugins > Search > Search setup contains Setup search engine only if the target section actually exists In order to set up the selected search engine As an admin I need to be able to click the link 'Setup search engine' but only if the target section actually exists Scenario: Selected search engine has an admin section Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | solr | And I log in as "admin" When I navigate to "Plugins > Search" in site administration Then "Setup search engine" "link" should exist Scenario: Selected search engine does not have an admin section Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | simpledb | And I log in as "admin" When I navigate to "Plugins > Search" in site administration Then I should see "Setup search engine" And "Setup search engine" "link" should not exist tests/behat/behat_search.php000064400000014233152265255700012126 0ustar00. /** * Behat search-related step definitions. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL used, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); use Behat\Gherkin\Node\TableNode as TableNode; use Moodle\BehatExtension\Exception\SkippedException; /** * Behat search-related step definitions. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_search extends behat_base { /** * Create event when starting on the front page. * * @Given /^I search for "(?P[^"]*)" using the header global search box$/ * @param string $query Query to search for */ public function i_search_for_using_the_header_global_search_box($query) { // Click the search icon. $this->execute("behat_general::i_click_on", [get_string('togglesearch', 'core'), 'button']); // Set the field. $this->execute('behat_forms::i_set_the_field_to', ['q', $query]); // Submit the form. $this->execute("behat_general::i_click_on_in_the", [get_string('performsearch', 'search'), 'button', '#usernavigation', 'css_element'] ); } /** * Sets results which will be returned for the next search. It will only return links to * activities at present. * * @Given /^global search expects the query "(?P[^"]*)" and will return:$/ * @param string $query Expected query value (just used to check the query passed to the engine) * @param TableNode $data Data rows */ public function global_search_expects_the_query_and_will_return($query, TableNode $data) { global $DB; $outdata = new stdClass(); $outdata->query = $query; $outdata->results = []; foreach ($data->getHash() as $rowdata) { // Check and get the data from the user-entered row. $input = [ 'type' => '', 'idnumber' => '', 'title' => '', 'content' => '', 'modified' => '' ]; foreach ($rowdata as $key => $value) { if (!array_key_exists($key, $input)) { throw new Exception('Field ' . $key . '" does not exist'); } $input[$key] = $value; } foreach (['idnumber', 'type'] as $requiredfield) { if (!$input[$requiredfield]) { throw new Exception('Must specify required field: ' . $requiredfield); } } // Check type (we only support activity at present, this could be extended to allow // faking other types of search results such as a user, course, or forum post). if ($input['type'] !== 'activity') { throw new Exception('Unsupported type: ' . $input['type']); } // Find the specified activity. $idnumber = $input['idnumber']; $cmid = $DB->get_field('course_modules', 'id', ['idnumber' => $idnumber], IGNORE_MISSING); if (!$cmid) { throw new Exception('Cannot find activity with idnumber: ' . $idnumber); } list ($course, $cm) = get_course_and_cm_from_cmid($cmid); $rec = $DB->get_record($cm->modname, ['id' => $cm->instance], '*', MUST_EXIST); $context = \context_module::instance($cm->id); // Set up the internal fields used in creating the search document. $out = new stdClass(); $out->itemid = $cm->instance; $out->componentname = 'mod_' . $cm->modname; $out->areaname = 'activity'; $out->fields = new stdClass(); $out->fields->contextid = $context->id; $out->fields->courseid = $course->id; if ($input['title']) { $out->fields->title = $input['title']; } else { $out->fields->title = $cm->name; } if ($input['content']) { $out->fields->content = $input['content']; } else { $out->fields->content = content_to_text($rec->intro, $rec->introformat); } if ($input['modified']) { $out->fields->modified = strtotime($input['modified']); } else { $out->fields->modified = $cm->added; } $out->extrafields = new stdClass(); $out->extrafields->coursefullname = $course->fullname; $outdata->results[] = $out; } set_config('behat_fakeresult', json_encode($outdata), 'core_search'); } /** * Updates the global search index to take account of any added activities. * * @Given /^I update the global search index$/ * @throws moodle_exception */ public function i_update_the_global_search_index() { \core_search\manager::instance()->index(false); } /** * This step looks to see if Solr is installed or skip the rest of the scenario otherwise * * @Given /^solr is installed/ */ public function solr_is_installed() { if (!function_exists('solr_get_version')) { throw new SkippedException('Skipping this scenario because Solr is not installed.'); } } } tests/top_result_test.php000064400000016271152265255700011676 0ustar00. namespace core_search; use stdClass; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Test for top results * * @package core_search * @author Nathan Nguyen * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class top_result_test extends \advanced_testcase { /** @var stdClass course 1 */ protected $course1; /** @var stdClass course 2 */ protected $course2; /** @var stdClass user 1 */ protected $user1; /** @var stdClass user 2 */ protected $user2; /** @var stdClass user 3 */ protected $user3; /** @var stdClass search engine */ protected $search; /** * Prepare test and users. */ private function prepare_test_courses_and_users(): void { global $DB; $this->setAdminUser(); // Search engine. $this->search = \testable_core_search::instance(new \search_simpledb\engine()); // Set default configurations. set_config('searchallavailablecourses', 1); set_config('searchincludeallcourses', 1); set_config('searchenablecategories', true); set_config('enableglobalsearch', true); set_config('searchmaxtopresults', 3); $teacher = $DB->get_record('role', ['shortname' => 'teacher']); $editingteacher = $DB->get_record('role', ['shortname' => 'editingteacher']); set_config('searchteacherroles', "$teacher->id, $editingteacher->id"); // Generate test data. $generator = $this->getDataGenerator(); // Courses. $this->course1 = $generator->create_course(['fullname' => 'Top course result 1']); // Ensure course 1 is indexed before course 2. $this->run_index(); $this->course2 = $generator->create_course(['fullname' => 'Top course result 2']); // User 1. $urecord1 = new \stdClass(); $urecord1->firstname = "User 1"; $urecord1->lastname = "Test"; $this->user1 = $generator->create_user($urecord1); // User 2. $urecord2 = new \stdClass(); $urecord2->firstname = "User 2"; $urecord2->lastname = "Test"; $this->user2 = $generator->create_user($urecord2); // User 3. $urecord3 = new \stdClass(); $urecord3->firstname = "User 3"; $urecord3->lastname = "Test"; $this->user3 = $generator->create_user($urecord3); } /** * Test course ranking */ public function test_search_course_rank(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); $this->setUser($this->user1); // Search query. $data = new \stdClass(); $data->q = 'Top course result'; $data->cat = 'core-all'; // Course 1 at the first index. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('Top course result 2', $docs[1]->get('title')); // Enrol user to course 2. $this->getDataGenerator()->enrol_user($this->user1->id, $this->course2->id, 'student'); // Course 2 at the first index. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 2', $docs[0]->get('title')); $this->assertEquals('Top course result 1', $docs[1]->get('title')); } /** * Test without teacher indexing */ public function test_search_with_no_course_teacher_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); set_config('searchteacherroles', ""); $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); // Search query. $data = new \stdClass(); $data->q = 'Top course result'; $data->cat = 'core-all'; // Only return the course. $this->run_index(); $docs = $this->search->search_top($data); $this->assertCount(2, $docs); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('Top course result 2', $docs[1]->get('title')); } /** * Test with teacher indexing */ public function test_search_with_course_teacher_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($this->user2->id, $this->course1->id, 'student'); // Search query. $data = new \stdClass(); $data->q = 'Top course result 1'; $data->cat = 'core-all'; // Return the course and the teachers. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('User 1 Test', $docs[1]->get('title')); } /** * Test with teacher indexing */ public function test_search_with_course_teacher_content_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); // Create forums as course content. $generator = $this->getDataGenerator(); // Course Teacher. $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); // Forums. $generator->create_module('forum', ['course' => $this->course1->id, 'name' => 'Forum 1, does not contain the keyword']); $generator->create_module('forum', ['course' => $this->course2->id, 'name' => 'Forum 2, contains keyword Top course result 1']); $this->run_index(); // Search query. $data = new \stdClass(); $data->q = 'Top course result 1'; $data->cat = 'core-all'; // Return the course and the teacher and the forum. $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('User 1 Test', $docs[1]->get('title')); $this->assertEquals('Forum 2, contains keyword Top course result 1', $docs[2]->get('title')); } /** * Execute indexing */ private function run_index(): void { // Indexing. $this->waitForSecond(); $this->search->index(false, 0); } } tests/manager_test.php000064400000234036152265255700011111 0ustar00. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Unit tests for search manager. * * @package core_search * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class manager_test extends \advanced_testcase { /** * Forum area id. * * @var string */ protected $forumpostareaid = null; /** * Courses area id. * * @var string */ protected $coursesareaid = null; public function setUp(): void { parent::setUp(); $this->forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $this->coursesareaid = \core_search\manager::generate_areaid('core_course', 'course'); } protected function tearDown(): void { // Stop it from faking time in the search manager (if set by test). \testable_core_search::fake_current_time(); parent::tearDown(); } public function test_search_enabled(): void { $this->resetAfterTest(); // Disabled by default. $this->assertFalse(\core_search\manager::is_global_search_enabled()); set_config('enableglobalsearch', true); $this->assertTrue(\core_search\manager::is_global_search_enabled()); set_config('enableglobalsearch', false); $this->assertFalse(\core_search\manager::is_global_search_enabled()); } /** * Tests the course search url is correct. * * @param bool|null $gsenabled Enable global search (null to leave as the default). * @param bool|null $allcourses Enable searching all courses (null to leave as the default). * @param bool|null $enablearea Enable the course search area (null to leave as the default). * @param string $expected The expected course search url. * @dataProvider data_course_search_url */ public function test_course_search_url(?bool $gsenabled, ?bool $allcourses, ?bool $enablearea, string $expected): void { $this->resetAfterTest(); if (!is_null($gsenabled)) { set_config('enableglobalsearch', $gsenabled); } if (!is_null($allcourses)) { set_config('searchincludeallcourses', $allcourses); } if (!is_null($enablearea)) { // Setup the course search area. $areaid = \core_search\manager::generate_areaid('core_course', 'course'); $area = \core_search\manager::get_search_area($areaid); $area->set_enabled($enablearea); } $this->assertEquals(new \moodle_url($expected), \core_search\manager::get_course_search_url()); } /** * Data for the test_course_search_url test. * * @return array[] */ public static function data_course_search_url(): array { return [ 'defaults' => [null, null, null, '/course/search.php'], 'enabled' => [true, true, true, '/search/index.php'], 'no all courses, no search area' => [true, false, false, '/course/search.php'], 'no search area' => [true, true, false, '/course/search.php'], 'no all courses' => [true, false, true, '/course/search.php'], 'disabled' => [false, false, false, '/course/search.php'], 'no global search' => [false, true, false, '/course/search.php'], 'no global search, no all courses' => [false, false, true, '/course/search.php'], 'no global search, no search area' => [false, true, false, '/course/search.php'], ]; } /** * Tests that we detect that global search can replace frontpage course search. * * @param bool|null $gsenabled Enable global search (null to leave as the default). * @param bool|null $allcourses Enable searching all courses (null to leave as the default). * @param bool|null $enablearea Enable the course search area (null to leave as the default). * @param bool $expected The expected result. * @dataProvider data_can_replace_course_search */ public function test_can_replace_course_search(?bool $gsenabled, ?bool $allcourses, ?bool $enablearea, bool $expected): void { $this->resetAfterTest(); if (!is_null($gsenabled)) { set_config('enableglobalsearch', $gsenabled); } if (!is_null($allcourses)) { set_config('searchincludeallcourses', $allcourses); } if (!is_null($enablearea)) { // Setup the course search area. $areaid = \core_search\manager::generate_areaid('core_course', 'course'); $area = \core_search\manager::get_search_area($areaid); $area->set_enabled($enablearea); } $this->assertEquals($expected, \core_search\manager::can_replace_course_search()); } /** * Data for the test_can_replace_course_search test. * * @return array[] */ public static function data_can_replace_course_search(): array { return [ 'defaults' => [null, null, null, false], 'enabled' => [true, true, true, true], 'no all courses, no search area' => [true, false, false, false], 'no search area' => [true, true, false, false], 'no all courses' => [true, false, true, false], 'disabled' => [false, false, false, false], 'no global search' => [false, true, false, false], 'no global search, no all courses' => [false, false, true, false], 'no global search, no search area' => [false, true, false, false], ]; } public function test_search_areas(): void { global $CFG; $this->resetAfterTest(); set_config('enableglobalsearch', true); $fakeareaid = \core_search\manager::generate_areaid('mod_unexisting', 'chihuaquita'); $searcharea = \core_search\manager::get_search_area($this->forumpostareaid); $this->assertInstanceOf('\core_search\base', $searcharea); $this->assertFalse(\core_search\manager::get_search_area($fakeareaid)); $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list()); $this->assertArrayNotHasKey($fakeareaid, \core_search\manager::get_search_areas_list()); // Enabled by default once global search is enabled. $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); list($componentname, $varname) = $searcharea->get_config_var_name(); set_config($varname . '_enabled', 0, $componentname); \core_search\manager::clear_static(); $this->assertArrayNotHasKey('mod_forum', \core_search\manager::get_search_areas_list(true)); set_config($varname . '_enabled', 1, $componentname); // Although the result is wrong, we want to check that \core_search\manager::get_search_areas_list returns cached results. $this->assertArrayNotHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); // Now we check the real result. \core_search\manager::clear_static(); $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); } public function test_search_config(): void { $this->resetAfterTest(); $search = \testable_core_search::instance(); // We should test both plugin types and core subsystems. No core subsystems available yet. $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); // Just with a couple of vars should be enough. $start = time() - 100; $end = time(); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals($start, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals($end, $configs[$this->forumpostareaid]->indexingend); $this->assertEquals(false, $configs[$this->forumpostareaid]->partial); try { $fakeareaid = \core_search\manager::generate_areaid('mod_unexisting', 'chihuaquita'); $search->reset_config($fakeareaid); $this->fail('An exception should be triggered if the provided search area does not exist.'); } catch (\moodle_exception $ex) { $this->assertStringContainsString($fakeareaid . ' search area is not available.', $ex->getMessage()); } // We clean it all but enabled components. $search->reset_config($this->forumpostareaid); $config = $searcharea->get_config(); $this->assertEquals(1, $config[$varname . '_enabled']); $this->assertEquals(0, $config[$varname . '_indexingstart']); $this->assertEquals(0, $config[$varname . '_indexingend']); $this->assertEquals(0, $config[$varname . '_lastindexrun']); $this->assertEquals(0, $config[$varname . '_partial']); // No caching. $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingend); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); // All components config should be reset. $search->reset_config(); $this->assertEquals(0, get_config($componentname, $varname . '_indexingstart')); $this->assertEquals(0, get_config($componentname, $varname . '_indexingend')); $this->assertEquals(0, get_config($componentname, $varname . '_lastindexrun')); // No caching. $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingend); } /** * Tests the get_last_indexing_duration method in the base area class. */ public function test_get_last_indexing_duration(): void { $this->resetAfterTest(); $search = \testable_core_search::instance(); $searcharea = $search->get_search_area($this->forumpostareaid); // When never indexed, the duration is false. $this->assertSame(false, $searcharea->get_last_indexing_duration()); // Set the start/end times. list($componentname, $varname) = $searcharea->get_config_var_name(); $start = time() - 100; $end = time(); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); // The duration should now be 100. $this->assertSame(100, $searcharea->get_last_indexing_duration()); } /** * Tests that partial indexing works correctly. */ public function test_partial_indexing(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // Index everything up to current. Ensure the course is older than current second so it // definitely doesn't get indexed again next time. $this->waitForSecond(); $search = \testable_core_search::instance(); $search->index(false, 0); $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); $this->assertFalse(get_config($componentname, $varname . '_partial')); // Add 3 discussions to the forum. $now = time(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now, 'name' => 'Frog']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 1, 'name' => 'Toad']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Zombie']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Werewolf']); time_sleep_until($now + 3); // Clear the count of added documents. $search->get_engine()->get_and_clear_added_documents(); // Make the search engine delay while indexing each document. $search->get_engine()->set_add_delay(1.2); // Use fake time, starting from now. \testable_core_search::fake_current_time(time()); // Index with a limit of 2 seconds - it should index 2 of the documents (after the second // one, it will have taken 2.4 seconds so it will stop). $search->index(false, 2); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(2, $added); $this->assertEquals('Frog', $added[0]->get('title')); $this->assertEquals('Toad', $added[1]->get('title')); $this->assertEquals(1, get_config($componentname, $varname . '_partial')); // Whilst 2.4 seconds of "time" have elapsed, the indexing duration is // measured in seconds, so should be 2. $this->assertEquals(2, $searcharea->get_last_indexing_duration()); // Add a label. $generator->create_module('label', ['course' => $course->id, 'intro' => 'Vampire']); // Wait to next second (so as to not reindex the label more than once, as it will now // be timed before the indexing run). $this->waitForSecond(); \testable_core_search::fake_current_time(time()); // Next index with 1 second limit should do the label and not the forum - the logic is, // if it spent ages indexing an area last time, do that one last on next run. $search->index(false, 1); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(1, $added); $this->assertEquals('Vampire', $added[0]->get('title')); // Index again with a 3 second limit - it will redo last post for safety (because of other // things possibly having the same time second), and then do the remaining one. (Note: // because it always does more than one second worth of items, it would actually index 2 // posts even if the limit were less than 2, we are testing it does 3 posts to make sure // the time limiting is actually working with the specified time.) $search->index(false, 3); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(3, $added); $this->assertEquals('Toad', $added[0]->get('title')); $remainingtitles = [$added[1]->get('title'), $added[2]->get('title')]; sort($remainingtitles); $this->assertEquals(['Werewolf', 'Zombie'], $remainingtitles); $this->assertFalse(get_config($componentname, $varname . '_partial')); // Index again - there should be nothing to index this time. $search->index(false, 2); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(0, $added); $this->assertFalse(get_config($componentname, $varname . '_partial')); } /** * Tests the progress display while indexing. * * This tests the different logic about displaying progress for slow/fast and * complete/incomplete processing. */ public function test_index_progress(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); // Set up the fake search area. $search = \testable_core_search::instance(); $area = new \core_mocksearch\search\mock_search_area(); $search->add_search_area('whatever', $area); $searchgenerator = $generator->get_plugin_generator('core_search'); $searchgenerator->setUp(); // Add records with specific time modified values. $time = strtotime('2017-11-01 01:00'); for ($i = 0; $i < 8; $i ++) { $searchgenerator->create_record((object)['timemodified' => $time]); $time += 60; } // Simulate slow progress on indexing and initial query. $now = strtotime('2017-11-11 01:00'); \testable_core_search::fake_current_time($now); $area->set_indexing_delay(10.123); $search->get_engine()->set_add_delay(15.789); // Run search indexing and check output. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 75, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Check for the standard text. $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); // Check for initial query performance indication. $this->assertStringContainsString('Initial query took 10.1 seconds.', $out); // Check for the two (approximately) every-30-seconds messages. $this->assertStringContainsString('01:00:41: Done to 1/11/17, 01:01', $out); $this->assertStringContainsString('01:01:13: Done to 1/11/17, 01:03', $out); // Check for the 'not complete' indicator showing when it was done until. $this->assertStringContainsString('Processed 5 records containing 5 documents, in 89.1 seconds ' . '(not complete; done to 1/11/17, 01:04)', $out); // Make the initial query delay less than 5 seconds, so it won't appear. Make the documents // quicker, so that the 30-second delay won't be needed. $area->set_indexing_delay(4.9); $search->get_engine()->set_add_delay(1); // Run search indexing (still partial) and check output. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 5, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); $this->assertStringNotContainsString('Initial query took', $out); $this->assertStringNotContainsString(': Done to', $out); $this->assertStringContainsString('Processed 2 records containing 2 documents, in 6.9 seconds ' . '(not complete; done to 1/11/17, 01:05).', $out); // Run the remaining items to complete it. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 100, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringNotContainsString('Stopping indexing due to time limit', $out); $this->assertStringNotContainsString('Initial query took', $out); $this->assertStringNotContainsString(': Done to', $out); $this->assertStringContainsString('Processed 3 records containing 3 documents, in 7.9 seconds.', $out); $searchgenerator->tearDown(); } /** * Tests that documents with modified time in the future are NOT indexed (as this would cause * a problem by preventing it from indexing other documents modified between now and the future * date). */ public function test_future_documents(): void { $this->resetAfterTest(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // Index everything up to current. Ensure the course is older than current second so it // definitely doesn't get indexed again next time. $this->waitForSecond(); $search = \testable_core_search::instance(); $search->index(false, 0); $search->get_engine()->get_and_clear_added_documents(); // Add 2 discussions to the forum, one of which happend just now, but the other is // incorrectly set to the future. $now = time(); $userid = get_admin()->id; $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $userid, 'timemodified' => $now, 'name' => 'Frog']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $userid, 'timemodified' => $now + 100, 'name' => 'Toad']); // Wait for a second so we're not actually on the same second as the forum post (there's a // 1 second overlap between indexing; it would get indexed in both checks below otherwise). $this->waitForSecond(); // Index. $search->index(false); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(1, $added); $this->assertEquals('Frog', $added[0]->get('title')); // Check latest time - it should be the same as $now, not the + 100. $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); $this->assertEquals($now, get_config($componentname, $varname . '_lastindexrun')); // Index again - there should be nothing to index this time. $search->index(false); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(0, $added); } /** * Tests that indexing a specified context works correctly. */ public function test_context_indexing(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and two forums and a page. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $now = time(); $forum1 = $generator->create_module('forum', ['course' => $course->id]); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum1->id, 'userid' => $USER->id, 'timemodified' => $now, 'name' => 'Frog']); $this->waitForSecond(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum1->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Zombie']); $forum2 = $generator->create_module('forum', ['course' => $course->id]); $this->waitForSecond(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum2->id, 'userid' => $USER->id, 'timemodified' => $now + 1, 'name' => 'Toad']); $generator->create_module('page', ['course' => $course->id]); $generator->create_module('forum', ['course' => $course->id]); // Index forum 1 only. $search = \testable_core_search::instance(); $buffer = new \progress_trace_buffer(new \text_progress_trace(), false); $result = $search->index_context(\context_module::instance($forum1->cmid), '', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output only processed 1 forum activity and 2 posts. $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 1 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); // Confirm that some areas for different types of context were skipped. $this->assertNotFalse(strpos($log, "area: Users\n Skipping")); $this->assertNotFalse(strpos($log, "area: Courses\n Skipping")); // Confirm that another module area had no results. $this->assertNotFalse(strpos($log, "area: Page\n No documents")); // Index whole course. $result = $search->index_context(\context_course::instance($course->id), '', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output processed 3 forum activities and 3 posts. $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 3 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 3 ")); // The course area was also included this time. $this->assertNotFalse(strpos($log, "area: Courses\n Processed 1 ")); // Confirm that another module area had results too. $this->assertNotFalse(strpos($log, "area: Page\n Processed 1 ")); // Index whole course, but only forum posts. $result = $search->index_context(\context_course::instance($course->id), 'mod_forum-post', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output processed 3 posts but not forum activities. $this->assertFalse(strpos($log, "area: Forum - activity information")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 3 ")); // Set time limit and retry index of whole course, taking 3 tries to complete it. $search->get_engine()->set_add_delay(0.4); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertFalse($result->complete); $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 2 ")); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer, $result->startfromarea, $result->startfromtime); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 2 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); $this->assertFalse($result->complete); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer, $result->startfromarea, $result->startfromtime); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); $this->assertTrue($result->complete); } /** * Adding this test here as get_areas_user_accesses process is the same, results just depend on the context level. * * @return void */ public function test_search_user_accesses(): void { global $DB; $this->resetAfterTest(); $frontpage = $DB->get_record('course', array('id' => SITEID)); $frontpagectx = \context_course::instance($frontpage->id); $course1 = $this->getDataGenerator()->create_course(); $course1ctx = \context_course::instance($course1->id); $course2 = $this->getDataGenerator()->create_course(); $course2ctx = \context_course::instance($course2->id); $course3 = $this->getDataGenerator()->create_course(); $course3ctx = \context_course::instance($course3->id); $teacher = $this->getDataGenerator()->create_user(); $teacherctx = \context_user::instance($teacher->id); $student = $this->getDataGenerator()->create_user(); $studentctx = \context_user::instance($student->id); $noaccess = $this->getDataGenerator()->create_user(); $noaccessctx = \context_user::instance($noaccess->id); $this->getDataGenerator()->enrol_user($teacher->id, $course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($student->id, $course1->id, 'student'); $frontpageforum = $this->getDataGenerator()->create_module('forum', array('course' => $frontpage->id)); $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $frontpageforumcontext = \context_module::instance($frontpageforum->cmid); $context1 = \context_module::instance($forum1->cmid); $context2 = \context_module::instance($forum2->cmid); $context3 = \context_module::instance($forum3->cmid); $forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id)); $context4 = \context_module::instance($forum4->cmid); $search = \testable_core_search::instance(); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $search->add_core_search_areas(); $search->add_search_area($mockareaid, new \core_mocksearch\search\mock_search_area()); $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); $sitectx = \context_course::instance(SITEID); // Can access the frontpage ones. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals(array($frontpageforumcontext->id => $frontpageforumcontext->id), $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id), $contexts[$this->coursesareaid]); $mockctxs = array($noaccessctx->id => $noaccessctx->id, $frontpagectx->id => $frontpagectx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); $this->setUser($teacher); $contexts = $search->get_areas_user_accesses()->usercontexts; $frontpageandcourse1 = array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id, $context2->id => $context2->id); $this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); $mockctxs = array($teacherctx->id => $teacherctx->id, $frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); $mockctxs = array($studentctx->id => $studentctx->id, $frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); // Hide the activity. set_coursemodule_visible($forum2->cmid, 0); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals(array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id), $contexts[$this->forumpostareaid]); // Now test course limited searches. set_coursemodule_visible($forum2->cmid, 1); $this->getDataGenerator()->enrol_user($student->id, $course2->id, 'student'); $contexts = $search->get_areas_user_accesses()->usercontexts; $allcontexts = array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id, $context2->id => $context2->id, $context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course1->id, $course2->id))->usercontexts; $allcontexts = array($context1->id => $context1->id, $context2->id => $context2->id, $context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course2->id))->usercontexts; $allcontexts = array($context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course1->id))->usercontexts; $allcontexts = array($context1->id => $context1->id, $context2->id => $context2->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); // Test context limited search with no course limit. $contexts = $search->get_areas_user_accesses(false, [$frontpageforumcontext->id, $course2ctx->id])->usercontexts; $this->assertEquals([$frontpageforumcontext->id => $frontpageforumcontext->id], $contexts[$this->forumpostareaid]); $this->assertEquals([$course2ctx->id => $course2ctx->id], $contexts[$this->coursesareaid]); // Test context limited search with course limit. $contexts = $search->get_areas_user_accesses([$course1->id, $course2->id], [$frontpageforumcontext->id, $course2ctx->id])->usercontexts; $this->assertArrayNotHasKey($this->forumpostareaid, $contexts); $this->assertEquals([$course2ctx->id => $course2ctx->id], $contexts[$this->coursesareaid]); // Single context and course. $contexts = $search->get_areas_user_accesses([$course1->id], [$context1->id])->usercontexts; $this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]); $this->assertArrayNotHasKey($this->coursesareaid, $contexts); // Enable "Include all visible courses" feature. set_config('searchincludeallcourses', 1); $contexts = $search->get_areas_user_accesses()->usercontexts; $expected = [ $sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id, $course3ctx->id => $course3ctx->id ]; // Check that a student has assess to all courses data when "searchincludeallcourses" is enabled. $this->assertEquals($expected, $contexts[$this->coursesareaid]); // But at the same time doesn't have access to activities in the courses that the student can't access. $this->assertFalse(key_exists($context4->id, $contexts[$this->forumpostareaid])); // For admins, this is still limited only if we specify the things, so it should be same. $this->setAdminUser(); $contexts = $search->get_areas_user_accesses([$course1->id], [$context1->id])->usercontexts; $this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]); $this->assertArrayNotHasKey($this->coursesareaid, $contexts); } /** * Tests the block support in get_search_user_accesses. * * @return void */ public function test_search_user_accesses_blocks(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create course and add HTML block. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $context1 = \context_course::instance($course1->id); $page = new \moodle_page(); $page->set_context($context1); $page->set_course($course1); $page->set_pagelayout('standard'); $page->set_pagetype('course-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); // Create another course with HTML blocks only in some weird page or a module page (not // yet supported, so both these blocks will be ignored). $course2 = $generator->create_course(); $context2 = \context_course::instance($course2->id); $page = new \moodle_page(); $page->set_context($context2); $page->set_course($course2); $page->set_pagelayout('standard'); $page->set_pagetype('bogus-page'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forumcontext = \context_module::instance($forum->cmid); $page = new \moodle_page(); $page->set_context($forumcontext); $page->set_course($course2); $page->set_pagelayout('standard'); $page->set_pagetype('mod-forum-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); // The third course has 2 HTML blocks. $course3 = $generator->create_course(); $context3 = \context_course::instance($course3->id); $page = new \moodle_page(); $page->set_context($context3); $page->set_course($course3); $page->set_pagelayout('standard'); $page->set_pagetype('course-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); $page->blocks->add_block_at_end_of_default_region('html'); // Student 1 belongs to all 3 courses. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $generator->enrol_user($student1->id, $course3->id, 'student'); // Student 2 belongs only to course 2. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course2->id, 'student'); // And the third student is only in course 3. $student3 = $generator->create_user(); $generator->enrol_user($student3->id, $course3->id, 'student'); $search = \testable_core_search::instance(); $search->add_core_search_areas(); // Admin gets 'true' result to function regardless of blocks. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // Student 1 gets all 3 block contexts. $this->setUser($student1); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayHasKey('block_html-content', $contexts); $this->assertCount(3, $contexts['block_html-content']); // Student 2 does not get any blocks. $this->setUser($student2); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayNotHasKey('block_html-content', $contexts); // Student 3 gets only two of them. $this->setUser($student3); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayHasKey('block_html-content', $contexts); $this->assertCount(2, $contexts['block_html-content']); // A course limited search for student 1 is the same as the student 3 search. $this->setUser($student1); $limitedcontexts = $search->get_areas_user_accesses([$course3->id])->usercontexts; $this->assertEquals($contexts['block_html-content'], $limitedcontexts['block_html-content']); // Get block context ids for the blocks that appear. $blockcontextids = $DB->get_fieldset_sql(' SELECT x.id FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? WHERE (parentcontextid = ? OR parentcontextid = ?) AND blockname = ? ORDER BY bi.id', [CONTEXT_BLOCK, $context1->id, $context3->id, 'html']); // Context limited search (no course). $contexts = $search->get_areas_user_accesses(false, [$blockcontextids[0], $blockcontextids[2]])->usercontexts; $this->assertCount(2, $contexts['block_html-content']); // Context limited search (with course 3). $contexts = $search->get_areas_user_accesses([$course2->id, $course3->id], [$blockcontextids[0], $blockcontextids[2]])->usercontexts; $this->assertCount(1, $contexts['block_html-content']); } /** * Tests retrieval of users search areas when limiting to a course the user is not enrolled in */ public function test_search_users_accesses_limit_non_enrolled_course(): void { global $DB; $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $search = \testable_core_search::instance(); $search->add_core_search_areas(); $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); // Limit courses to search to only those the user is enrolled in. set_config('searchallavailablecourses', 0); $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts; $this->assertNotEmpty($usercontexts); $this->assertArrayNotHasKey('core_course-course', $usercontexts); // This config ensures the search will also include courses the user can view. set_config('searchallavailablecourses', 1); // Allow "Authenticated user" role to view the course without being enrolled in it. $userrole = $DB->get_record('role', ['shortname' => 'user'], '*', MUST_EXIST); role_change_permission($userrole->id, $context, 'moodle/course:view', CAP_ALLOW); $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts; $this->assertNotEmpty($usercontexts); $this->assertArrayHasKey('core_course-course', $usercontexts); $this->assertEquals($context->id, reset($usercontexts['core_course-course'])); } /** * Test get_areas_user_accesses with regard to the 'all available courses' config option. * * @return void */ public function test_search_user_accesses_allavailable(): void { global $DB, $CFG; $this->resetAfterTest(); // Front page, including a forum. $frontpage = $DB->get_record('course', array('id' => SITEID)); $forumfront = $this->getDataGenerator()->create_module('forum', array('course' => $frontpage->id)); $forumfrontctx = \context_module::instance($forumfront->cmid); // Course 1 does not allow guest access. $course1 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED, 'enrol_guest_password_0' => '')); $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum1ctx = \context_module::instance($forum1->cmid); // Course 2 does not allow guest but is accessible by all users. $course2 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED, 'enrol_guest_password_0' => '')); $course2ctx = \context_course::instance($course2->id); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forum2ctx = \context_module::instance($forum2->cmid); assign_capability('moodle/course:view', CAP_ALLOW, $CFG->defaultuserroleid, $course2ctx->id); // Course 3 allows guest access without password. $course3 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED, 'enrol_guest_password_0' => '')); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forum3ctx = \context_module::instance($forum3->cmid); // Student user is enrolled in course 1. $student = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($student->id, $course1->id, 'student'); // No access user is just a user with no permissions. $noaccess = $this->getDataGenerator()->create_user(); // First test without the all available option. $search = \testable_core_search::instance(); // Admin user can access everything. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // No-access user can access only the front page forum. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Student can access the front page forum plus the enrolled one. $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum1ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Now turn on the all available option. set_config('searchallavailablecourses', 1); // Admin user can access everything. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // No-access user can access the front page forum and course 2, 3. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum2ctx->id, $forum3ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Student can access the front page forum plus the enrolled one plus courses 2, 3. $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum1ctx->id, $forum2ctx->id, $forum3ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); } /** * Tests group-related aspects of the get_areas_user_accesses function. */ public function test_search_user_accesses_groups(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create 2 courses each with 2 groups and 2 forums (separate/visible groups). $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $group1 = $generator->create_group(['courseid' => $course1->id]); $group2 = $generator->create_group(['courseid' => $course1->id]); $group3 = $generator->create_group(['courseid' => $course2->id]); $group4 = $generator->create_group(['courseid' => $course2->id]); $forum1s = $generator->create_module('forum', ['course' => $course1->id, 'groupmode' => SEPARATEGROUPS]); $id1s = \context_module::instance($forum1s->cmid)->id; $forum1v = $generator->create_module('forum', ['course' => $course1->id, 'groupmode' => VISIBLEGROUPS]); $id1v = \context_module::instance($forum1v->cmid)->id; $forum2s = $generator->create_module('forum', ['course' => $course2->id, 'groupmode' => SEPARATEGROUPS]); $id2s = \context_module::instance($forum2s->cmid)->id; $forum2n = $generator->create_module('forum', ['course' => $course2->id, 'groupmode' => NOGROUPS]); $id2n = \context_module::instance($forum2n->cmid)->id; // Get search instance. $search = \testable_core_search::instance(); $search->add_core_search_areas(); // User 1 is a manager in one course and a student in the other one. They belong to // all of the groups 1, 2, 3, and 4. $user1 = $generator->create_user(); $generator->enrol_user($user1->id, $course1->id, 'manager'); $generator->enrol_user($user1->id, $course2->id, 'student'); groups_add_member($group1, $user1); groups_add_member($group2, $user1); groups_add_member($group3, $user1); groups_add_member($group4, $user1); $this->setUser($user1); $accessinfo = $search->get_areas_user_accesses(); $contexts = $accessinfo->usercontexts; // Double-check all the forum contexts. $postcontexts = $contexts['mod_forum-post']; sort($postcontexts); $this->assertEquals([$id1s, $id1v, $id2s, $id2n], $postcontexts); // Only the context in the second course (no accessallgroups) is restricted. $restrictedcontexts = $accessinfo->separategroupscontexts; sort($restrictedcontexts); $this->assertEquals([$id2s], $restrictedcontexts); // Only the groups from the second course (no accessallgroups) are included. $groupids = $accessinfo->usergroups; sort($groupids); $this->assertEquals([$group3->id, $group4->id], $groupids); // User 2 is a student in each course and belongs to groups 2 and 4. $user2 = $generator->create_user(); $generator->enrol_user($user2->id, $course1->id, 'student'); $generator->enrol_user($user2->id, $course2->id, 'student'); groups_add_member($group2, $user2); groups_add_member($group4, $user2); $this->setUser($user2); $accessinfo = $search->get_areas_user_accesses(); $contexts = $accessinfo->usercontexts; // Double-check all the forum contexts. $postcontexts = $contexts['mod_forum-post']; sort($postcontexts); $this->assertEquals([$id1s, $id1v, $id2s, $id2n], $postcontexts); // Both separate groups forums are restricted. $restrictedcontexts = $accessinfo->separategroupscontexts; sort($restrictedcontexts); $this->assertEquals([$id1s, $id2s], $restrictedcontexts); // Groups from both courses are included. $groupids = $accessinfo->usergroups; sort($groupids); $this->assertEquals([$group2->id, $group4->id], $groupids); // User 3 is a manager at system level. $user3 = $generator->create_user(); role_assign($DB->get_field('role', 'id', ['shortname' => 'manager'], MUST_EXIST), $user3->id, \context_system::instance()); $this->setUser($user3); $accessinfo = $search->get_areas_user_accesses(); // Nothing is restricted and no groups are relevant. $this->assertEquals([], $accessinfo->separategroupscontexts); $this->assertEquals([], $accessinfo->usergroups); } /** * test_is_search_area * * @return void */ public function test_is_search_area(): void { $this->assertFalse(\testable_core_search::is_search_area('\asd\asd')); $this->assertFalse(\testable_core_search::is_search_area('\mod_forum\search\posta')); $this->assertFalse(\testable_core_search::is_search_area('\core_search\base_mod')); $this->assertTrue(\testable_core_search::is_search_area('\mod_forum\search\post')); $this->assertTrue(\testable_core_search::is_search_area('\\mod_forum\\search\\post')); $this->assertTrue(\testable_core_search::is_search_area('mod_forum\\search\\post')); } /** * Tests the request_index function used for reindexing certain contexts. This only tests * adding things to the request list, it doesn't test that they are actually indexed by the * scheduled task. */ public function test_request_index(): void { global $DB; $this->resetAfterTest(); $course1 = $this->getDataGenerator()->create_course(); $course1ctx = \context_course::instance($course1->id); $course2 = $this->getDataGenerator()->create_course(); $course2ctx = \context_course::instance($course2->id); $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course1->id]); $forum1ctx = \context_module::instance($forum1->cmid); $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course2->id]); $forum2ctx = \context_module::instance($forum2->cmid); // Initially no requests. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Request update for course 1, all areas. \core_search\manager::request_index($course1ctx); // Check all details of entry. $results = array_values($DB->get_records('search_index_requests')); $this->assertCount(1, $results); $this->assertEquals($course1ctx->id, $results[0]->contextid); $this->assertEquals('', $results[0]->searcharea); $now = time(); $this->assertLessThanOrEqual($now, $results[0]->timerequested); $this->assertGreaterThan($now - 10, $results[0]->timerequested); $this->assertEquals('', $results[0]->partialarea); $this->assertEquals(0, $results[0]->partialtime); // Request forum 1, all areas; not added as covered by course 1. \core_search\manager::request_index($forum1ctx); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request forum 1, specific area; not added as covered by course 1 all areas. \core_search\manager::request_index($forum1ctx, 'forum-post'); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 1 again, specific area; not added as covered by all areas. \core_search\manager::request_index($course1ctx, 'forum-post'); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 1 again, all areas; not needed as covered already. \core_search\manager::request_index($course1ctx); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 2, specific area. \core_search\manager::request_index($course2ctx, 'label-activity'); // Note: I'm ordering by ID for convenience - this is dangerous in real code (see MDL-43447) // but in a unit test it shouldn't matter as nobody is using clustered databases for unit // test. $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(2, $results); $this->assertEquals($course1ctx->id, $results[0]->contextid); $this->assertEquals($course2ctx->id, $results[1]->contextid); $this->assertEquals('label-activity', $results[1]->searcharea); // Request forum 2, same specific area; not added. \core_search\manager::request_index($forum2ctx, 'label-activity'); $this->assertEquals(2, $DB->count_records('search_index_requests')); // Request forum 2, different specific area; added. \core_search\manager::request_index($forum2ctx, 'forum-post'); $this->assertEquals(3, $DB->count_records('search_index_requests')); // Request forum 2, all areas; also added. (Note: This could obviously remove the previous // one, but for simplicity, I didn't make it do that; also it could perhaps cause problems // if we had already begun processing the previous entry.) \core_search\manager::request_index($forum2ctx); $this->assertEquals(4, $DB->count_records('search_index_requests')); // Clear queue and do tests relating to priority. $DB->delete_records('search_index_requests'); // Request forum 1, specific area, priority 100. \core_search\manager::request_index($forum1ctx, 'forum-post', 100); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(100, $results[0]->indexpriority); // Request forum 1, same area, lower priority; no change. \core_search\manager::request_index($forum1ctx, 'forum-post', 99); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(100, $results[0]->indexpriority); // Request forum 1, same area, higher priority; priority stored changes. \core_search\manager::request_index($forum1ctx, 'forum-post', 101); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(101, $results[0]->indexpriority); // Request forum 1, all areas, lower priority; adds second entry. \core_search\manager::request_index($forum1ctx, '', 100); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(2, $results); $this->assertEquals(100, $results[1]->indexpriority); // Request course 1, all areas, lower priority; adds third entry. \core_search\manager::request_index($course1ctx, '', 99); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(3, $results); $this->assertEquals(99, $results[2]->indexpriority); } /** * Tests the process_index_requests function. */ public function test_process_index_requests(): void { global $DB; $this->resetAfterTest(); $search = \testable_core_search::instance(); // When there are no index requests, nothing gets logged. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->process_index_requests(0.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertEquals('', $out); // Set up the course with 3 forums. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'TCourse']); $forum1 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum1']); $forum2 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum2']); $forum3 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum3']); // Hack the forums so they have different creation times. $now = time(); $DB->set_field('forum', 'timemodified', $now - 30, ['id' => $forum1->id]); $DB->set_field('forum', 'timemodified', $now - 20, ['id' => $forum2->id]); $DB->set_field('forum', 'timemodified', $now - 10, ['id' => $forum3->id]); $forum2time = $now - 20; // Make 2 index requests. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_course::instance($course->id), 'mod_label-activity'); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_module::instance($forum1->cmid)); // Run with no time limit. $search->process_index_requests(0.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Check that it's done both areas. $this->assertStringContainsString( 'Indexing requested context: Course: TCourse (search area: mod_label-activity)', $out); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_label-activity)', $out); $this->assertStringContainsString('Indexing requested context: Forum: TForum1', $out); $this->assertStringContainsString('Completed requested context: Forum: TForum1', $out); // Check the requests database table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Request indexing the course a couple of times. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_course::instance($course->id), 'mod_forum-activity'); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_course::instance($course->id), 'mod_forum-post'); // Do the processing again with a time limit and indexing delay. The time limit is too // small; because of the way the logic works, this means it will index 2 activities. $search->get_engine()->set_add_delay(0.2); $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Confirm the right wrapper information was logged. $this->assertStringContainsString( 'Indexing requested context: Course: TCourse (search area: mod_forum-activity)', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); $this->assertStringContainsString( 'Ending requested context: Course: TCourse (search area: mod_forum-activity)', $out); // Check the database table has been updated with progress. $records = array_values($DB->get_records('search_index_requests', null, 'searcharea')); $this->assertEquals('mod_forum-activity', $records[0]->partialarea); $this->assertEquals($forum2time, $records[0]->partialtime); // Run again and confirm it now finishes. $search->process_index_requests(2.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_forum-activity)', $out); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_forum-post)', $out); // Confirm table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Make 2 requests - first one is low priority. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_module::instance($forum1->cmid), 'mod_forum-activity', \core_search\manager::INDEX_PRIORITY_REINDEXING); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_module::instance($forum2->cmid), 'mod_forum-activity'); // Process with short time limit and confirm it does the second one first. $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Forum: TForum2 (search area: mod_forum-activity)', $out); $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Forum: TForum1 (search area: mod_forum-activity)', $out); // Make a request for a course context... $course = $generator->create_course(); $context = \context_course::instance($course->id); $search::request_index($context); // ...but then delete it (note: delete_course spews output, so we throw it away). ob_start(); delete_course($course); ob_end_clean(); // Process requests - it should only note the deleted context. $search->process_index_requests(10, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Skipped deleted context: ' . $context->id, $out); // Confirm request table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); } /** * Test search area categories. */ public function test_get_search_area_categories(): void { $categories = \core_search\manager::get_search_area_categories(); $this->assertTrue(is_array($categories)); $this->assertTrue(count($categories) >= 4); // We always should have 4 core categories. $this->assertArrayHasKey('core-all', $categories); $this->assertArrayHasKey('core-course-content', $categories); $this->assertArrayHasKey('core-courses', $categories); $this->assertArrayHasKey('core-users', $categories); foreach ($categories as $category) { $this->assertInstanceOf('\core_search\area_category', $category); } } /** * Test that we can find out if search area categories functionality is enabled. */ public function test_is_search_area_categories_enabled(): void { $this->resetAfterTest(); $this->assertFalse(\core_search\manager::is_search_area_categories_enabled()); set_config('searchenablecategories', 1); $this->assertTrue(\core_search\manager::is_search_area_categories_enabled()); set_config('searchenablecategories', 0); $this->assertFalse(\core_search\manager::is_search_area_categories_enabled()); } /** * Test that we can find out if hiding all results category is enabled. */ public function test_should_hide_all_results_category(): void { $this->resetAfterTest(); $this->assertEquals(0, \core_search\manager::should_hide_all_results_category()); set_config('searchhideallcategory', 1); $this->assertEquals(1, \core_search\manager::should_hide_all_results_category()); set_config('searchhideallcategory', 0); $this->assertEquals(0, \core_search\manager::should_hide_all_results_category()); } /** * Test that we can get default search category name. */ public function test_get_default_area_category_name(): void { $this->resetAfterTest(); $expected = 'core-all'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); set_config('searchhideallcategory', 1); $expected = 'core-course-content'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); set_config('searchhideallcategory', 0); $expected = 'core-all'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); } /** * Test that we can get correct search area category by its name. */ public function test_get_search_area_category_by_name(): void { $this->resetAfterTest(); $testcategory = \core_search\manager::get_search_area_category_by_name('test_random_name'); $this->assertEquals('core-all', $testcategory->get_name()); $testcategory = \core_search\manager::get_search_area_category_by_name('core-courses'); $this->assertEquals('core-courses', $testcategory->get_name()); set_config('searchhideallcategory', 1); $testcategory = \core_search\manager::get_search_area_category_by_name('test_random_name'); $this->assertEquals('core-course-content', $testcategory->get_name()); } /** * Test that we can check that "Include all visible courses" feature is enabled. */ public function test_include_all_courses_enabled(): void { $this->resetAfterTest(); $this->assertFalse(\core_search\manager::include_all_courses()); set_config('searchincludeallcourses', 1); $this->assertTrue(\core_search\manager::include_all_courses()); } /** * Test that we can correctly build a list of courses for a course filter for the search results. */ public function test_build_limitcourseids(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course3 = $this->getDataGenerator()->create_course(); $course4 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($USER->id, $course1->id); $this->getDataGenerator()->enrol_user($USER->id, $course3->id); $search = \testable_core_search::instance(); $formdata = new \stdClass(); $formdata->courseids = []; $formdata->mycoursesonly = false; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals(false, $limitcourseids); $formdata->courseids = []; $formdata->mycoursesonly = true; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id, $course3->id], $limitcourseids); $formdata->courseids = [$course1->id, $course2->id, $course4->id]; $formdata->mycoursesonly = false; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id, $course2->id, $course4->id], $limitcourseids); $formdata->courseids = [$course1->id, $course2->id, $course4->id]; $formdata->mycoursesonly = true; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id], $limitcourseids); } /** * Test data for test_parse_areaid test fucntion. * * @return array */ public static function parse_search_area_id_data_provider(): array { return [ ['mod_book-chapter', ['mod_book', 'search_chapter']], ['mod_customcert-activity', ['mod_customcert', 'search_activity']], ['core_course-mycourse', ['core_search', 'core_course_mycourse']], ]; } /** * Test that manager class can parse area id correctly. * @dataProvider parse_search_area_id_data_provider * * @param string $areaid Area id to parse. * @param array $expected Expected result of parsing. */ public function test_parse_search_area_id($areaid, $expected): void { $this->assertEquals($expected, \core_search\manager::parse_areaid($areaid)); } /** * Test that manager class will throw an exception when parsing an invalid area id. */ public function test_parse_invalid_search_area_id(): void { $this->expectException('coding_exception'); $this->expectExceptionMessage('Trying to parse invalid search area id invalid_area'); \core_search\manager::parse_areaid('invalid_area'); } /** * Test getting a coding exception when trying to lean up existing search area. */ public function test_cleaning_up_existing_search_area(): void { $expectedmessage = "Area mod_assign-activity exists. Please use appropriate search area class to manipulate the data."; $this->expectException('coding_exception'); $this->expectExceptionMessage($expectedmessage); \core_search\manager::clean_up_non_existing_area('mod_assign-activity'); } /** * Test clean up of non existing search area. */ public function test_clean_up_non_existing_search_area(): void { global $DB; $this->resetAfterTest(); $areaid = 'core_course-mycourse'; $plugin = 'core_search'; // Get all settings to DB and make sure they are there. foreach (\core_search\base::get_settingnames() as $settingname) { $record = new \stdClass(); $record->plugin = $plugin; $record->name = 'core_course_mycourse'. $settingname; $record->value = 'test'; $DB->insert_record('config_plugins', $record); $this->assertTrue($DB->record_exists('config_plugins', ['plugin' => $plugin, 'name' => $record->name])); } // Clean up the search area. \core_search\manager::clean_up_non_existing_area($areaid); // Check that records are not in DB after we ran clean up. foreach (\core_search\base::get_settingnames() as $settingname) { $plugin = 'core_search'; $name = 'core_course_mycourse'. $settingname; $this->assertFalse($DB->record_exists('config_plugins', ['plugin' => $plugin, 'name' => $name])); } } /** * Tests the context_deleted, course_deleting_start, and course_deleting_finish methods. */ public function test_context_deletion(): void { $this->resetAfterTest(); // Create one course with 4 activities, and another with one. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $page1 = $generator->create_module('page', ['course' => $course1]); $context1 = \context_module::instance($page1->cmid); $page2 = $generator->create_module('page', ['course' => $course1]); $page3 = $generator->create_module('page', ['course' => $course1]); $context3 = \context_module::instance($page3->cmid); $page4 = $generator->create_module('page', ['course' => $course1]); $course2 = $generator->create_course(); $page5 = $generator->create_module('page', ['course' => $course2]); $context5 = \context_module::instance($page5->cmid); // Also create a user. $user = $generator->create_user(); $usercontext = \context_user::instance($user->id); $search = \testable_core_search::instance(); // Delete two of the pages individually. course_delete_module($page1->cmid); course_delete_module($page3->cmid); // Delete the course with another two. delete_course($course1->id, false); // Delete the user. delete_user($user); // Delete the page from the other course. course_delete_module($page5->cmid); // It should have deleted the contexts and the course, but not the contexts in the course. $expected = [ ['context', $context1->id], ['context', $context3->id], ['course', $course1->id], ['context', $usercontext->id], ['context', $context5->id] ]; $this->assertEquals($expected, $search->get_engine()->get_and_clear_deletes()); } /** * Tests the indexing delay (used to avoid race conditions) in {@see manager::index()}. * * @covers \core_search\manager::index */ public function test_indexing_delay(): void { global $USER, $CFG; $this->resetAfterTest(); // Normally the indexing delay is turned off for test scripts because we don't want to have // to wait 5 seconds after creating anything to index it and it's not like there will be a // race condition (indexing doesn't run at same time as adding). This turns it on. $CFG->searchindexingdelayfortestscript = true; $this->setAdminUser(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // Skip ahead 5 seconds so everything gets indexed. $now = time(); $now += manager::INDEXING_DELAY; $search = \testable_core_search::instance(); $search->fake_current_time($now); $search->index(); $search->get_engine()->get_and_clear_added_documents(); // Basic discussion data. $basicdata = [ 'course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, ]; // Discussion so old it's prior to indexing delay (not realistic). $generator->get_plugin_generator('mod_forum')->create_discussion(array_merge($basicdata, ['timemodified' => $now - manager::INDEXING_DELAY, 'name' => 'Frog'])); // Discussion just within indexing delay (simulates if it took a while to add to database). $generator->get_plugin_generator('mod_forum')->create_discussion(array_merge($basicdata, ['timemodified' => $now - (manager::INDEXING_DELAY - 1), 'name' => 'Toad'])); // Move time along a bit. $now += 100; $search->fake_current_time($now); // Discussion that happened 5 seconds before the new now. $generator->get_plugin_generator('mod_forum')->create_discussion(array_merge($basicdata, ['timemodified' => $now - (manager::INDEXING_DELAY), 'name' => 'Zombie'])); // This one only happened 4 seconds before so it shouldn't be indexed yet. $generator->get_plugin_generator('mod_forum')->create_discussion(array_merge($basicdata, ['timemodified' => $now - (manager::INDEXING_DELAY - 1), 'name' => 'Werewolf'])); // Reindex and check that it added the middle two discussions. $search->index(); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(2, $added); $this->assertEquals('Toad', $added[0]->get('title')); $this->assertEquals('Zombie', $added[1]->get('title')); // Move time forwards a couple of seconds and now the last one will get indexed. $now += 2; $search->fake_current_time($now); $search->index(); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(1, $added); $this->assertEquals('Werewolf', $added[0]->get('title')); } } course_teacher.php000064400000015752152266360430010271 0ustar00. /** * Index teachers in a course * * @package core_user * @author Nathan Nguyen * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\search; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/user/lib.php'); /** * Search for user role assignment in a course * * @package core_user * @author Nathan Nguyen * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_teacher extends \core_search\base { /** * The context levels the search implementation is working on. * * @var array */ protected static $levels = [CONTEXT_COURSE]; /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return 'course_teacher'; } /** * Returns recordset containing required data attributes for indexing. * * @param number $modifiedfrom * @param \context|null $context Optional context to restrict scope of returned results * @return \moodle_recordset|null Recordset (or null if no results) */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; $teacherroleids = get_config('core', 'searchteacherroles'); // Only index teacher roles. if (!empty($teacherroleids)) { $teacherroleids = explode(',', $teacherroleids); list($insql, $inparams) = $DB->get_in_or_equal($teacherroleids, SQL_PARAMS_NAMED); } else { // Do not index at all. list($insql, $inparams) = [' = :roleid', ['roleid' => 0]]; } $params = [ 'coursecontext' => CONTEXT_COURSE, 'modifiedfrom' => $modifiedfrom ]; $params = array_merge($params, $inparams); $recordset = $DB->get_recordset_sql(" SELECT u.*, ra.contextid, r.shortname as roleshortname, ra.id as itemid, ra.timemodified as timeassigned FROM {role_assignments} ra JOIN {context} ctx ON ctx.id = ra.contextid AND ctx.contextlevel = :coursecontext JOIN {user} u ON u.id = ra.userid JOIN {role} r ON r.id = ra.roleid WHERE ra.timemodified >= :modifiedfrom AND r.id $insql ORDER BY ra.timemodified ASC", $params); return $recordset; } /** * Returns document instances for each record in the recordset. * * @param \stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { $context = \context::instance_by_id($record->contextid); // Content. if ($context->contextlevel == CONTEXT_COURSE) { $course = get_course($context->instanceid); $contentdata = new \stdClass(); $contentdata->role = ucfirst($record->roleshortname); $contentdata->course = $course->fullname; $content = get_string('content:courserole', 'core_search', $contentdata); } else { return false; } $doc = \core_search\document_factory::instance($record->itemid, $this->componentname, $this->areaname); // Assigning properties to our document. $doc->set('title', content_to_text(fullname($record), false)); $doc->set('contextid', $context->id); $doc->set('courseid', $context->instanceid); $doc->set('itemid', $record->itemid); $doc->set('modified', $record->timeassigned); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('userid', $record->id); $doc->set('content', $content); // Check if this document should be considered new. if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timeassigned) { $doc->set_is_new(true); } return $doc; } /** * Checking whether I can access a document * * @param int $id user id * @return int */ public function check_access($id) { $user = $this->get_user($id); if (!$user || $user->deleted) { return \core_search\manager::ACCESS_DELETED; } if (user_can_view_profile($user)) { return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DENIED; } /** * Returns a url to the document context. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { $user = $this->get_user($doc->get('itemid')); $courseid = $doc->get('courseid'); return new \moodle_url('/user/view.php', array('id' => $user->id, 'course' => $courseid)); } /** * Returns the user fullname to display as document title * * @param \core_search\document $doc * @return string User fullname */ public function get_document_display_title(\core_search\document $doc) { $user = $this->get_user($doc->get('itemid')); return fullname($user); } /** * Get user based on role assignment id * * @param int $itemid role assignment id * @return mixed */ private function get_user($itemid) { global $DB; $sql = "SELECT u.* FROM {user} u JOIN {role_assignments} ra ON ra.userid = u.id WHERE ra.id = :raid"; return $DB->get_record_sql($sql, array('raid' => $itemid)); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [\core_search\manager::SEARCH_AREA_CATEGORY_ALL, \core_search\manager::SEARCH_AREA_CATEGORY_USERS]; } /** * Link to the teacher in the course * * @param \core_search\document $doc the document * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } } user.php000064400000015454152266360430006253 0ustar00. /** * Search area for Users for whom I have authority to view profile. * * @package core_user * @copyright 2016 Devang Gaur {@link http://www.devanggaur.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\search; require_once($CFG->dirroot . '/user/lib.php'); defined('MOODLE_INTERNAL') || die(); /** * Search area for Users for whom I have access to view profile. * * @package core_user * @copyright 2016 Devang Gaur {@link http://www.devanggaur.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user extends \core_search\base { /** * Returns recordset containing required data attributes for indexing. * * @param number $modifiedfrom * @param \context|null $context Optional context to restrict scope of returned results * @return \moodle_recordset|null Recordset (or null if no results) */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; // Prepare query conditions. $where = 'timemodified >= ? AND deleted = ? AND confirmed = ?'; $params = [$modifiedfrom, 0, 1]; // Handle context types. if (!$context) { $context = \context_system::instance(); } switch ($context->contextlevel) { case CONTEXT_MODULE: case CONTEXT_BLOCK: case CONTEXT_COURSE: case CONTEXT_COURSECAT: // These contexts cannot contain any users. return null; case CONTEXT_USER: // Restrict to specific user. $where .= ' AND id = ?'; $params[] = $context->instanceid; break; case CONTEXT_SYSTEM: break; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return $DB->get_recordset_select('user', $where, $params); } /** * Returns document instances for each record in the recordset. * * @param \stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { $context = \context_system::instance(); // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); // Include all alternate names in title. $array = []; foreach (\core_user\fields::get_name_fields(true) as $field) { $array[$field] = $record->$field; } $fullusername = join(' ', $array); // Assigning properties to our document. $doc->set('title', content_to_text($fullusername, false)); $doc->set('contextid', $context->id); $doc->set('courseid', SITEID); $doc->set('itemid', $record->id); $doc->set('modified', $record->timemodified); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('content', content_to_text($record->description, $record->descriptionformat)); // Check if this document should be considered new. if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timecreated) { // If the document was created after the last index time, it must be new. $doc->set_is_new(true); } return $doc; } /** * Returns the user fullname to display as document title * * @param \core_search\document $doc * @return string User fullname */ public function get_document_display_title(\core_search\document $doc) { $user = \core_user::get_user($doc->get('itemid')); return fullname($user); } /** * Checking whether I can access a document * * @param int $id user id * @return int */ public function check_access($id) { global $DB, $USER; $user = $DB->get_record('user', array('id' => $id)); if (!$user || $user->deleted) { return \core_search\manager::ACCESS_DELETED; } if (user_can_view_profile($user)) { return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DENIED; } /** * Returns a url to the profile page of user. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } /** * Returns a url to the document context. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { return new \moodle_url('/user/profile.php', array('id' => $doc->get('itemid'))); } /** * Returns true if this area uses file indexing. * * @return bool */ public function uses_file_indexing() { return true; } /** * Return the context info required to index files for * this search area. * * Should be onerridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array( 'profile' // Fileareas. ); return $fileareas; } /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return 'user'; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * * @return \core_search\document_icon */ public function get_doc_icon(\core_search\document $doc): \core_search\document_icon { return new \core_search\document_icon('i/user'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [\core_search\manager::SEARCH_AREA_CATEGORY_USERS]; } } search_test.php000064400000073764152266360460007614 0ustar00. /** * Course global search unit tests. * * @package core_course * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); /** * Provides the unit tests for course global search. * * @package core * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class search_test extends \advanced_testcase { /** * @var string Area id */ protected $coursesareaid = null; /** * @var string Area id for sections */ protected $sectionareaid = null; /** * @var string Area id for custom fields. */ protected $customfieldareaid = null; public function setUp(): void { parent::setUp(); $this->resetAfterTest(true); set_config('enableglobalsearch', true); $this->coursesareaid = \core_search\manager::generate_areaid('core_course', 'course'); $this->sectionareaid = \core_search\manager::generate_areaid('core_course', 'section'); $this->customfieldareaid = \core_search\manager::generate_areaid('core_course', 'customfield'); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); } /** * Indexing courses contents. * * @return void */ public function test_courses_indexing(): void { // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->coursesareaid); $this->assertInstanceOf('\core_course\search\course', $searcharea); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); $course1 = self::getDataGenerator()->create_course(); $course2 = self::getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student'); $record = new \stdClass(); $record->course = $course1->id; // All records. $recordset = $searcharea->get_recordset_by_timestamp(0); $this->assertTrue($recordset->valid()); $nrecords = 0; foreach ($recordset as $record) { $this->assertInstanceOf('stdClass', $record); $doc = $searcharea->get_document($record); $this->assertInstanceOf('\core_search\document', $doc); $nrecords++; } // If there would be an error/failure in the foreach above the recordset would be closed on shutdown. $recordset->close(); $this->assertEquals(3, $nrecords); // The +2 is to prevent race conditions. $recordset = $searcharea->get_recordset_by_timestamp(time() + 2); // No new records. $this->assertFalse($recordset->valid()); $recordset->close(); } /** * Tests course indexing support for contexts. */ public function test_courses_indexing_contexts(): void { global $DB, $USER, $SITE; $searcharea = \core_search\manager::get_search_area($this->coursesareaid); // Create some courses in categories, and a forum. $generator = $this->getDataGenerator(); $cat1 = $generator->create_category(); $course1 = $generator->create_course(['category' => $cat1->id]); $cat2 = $generator->create_category(['parent' => $cat1->id]); $course2 = $generator->create_course(['category' => $cat2->id]); $cat3 = $generator->create_category(); $course3 = $generator->create_course(['category' => $cat3->id]); $forum = $generator->create_module('forum', ['course' => $course1->id]); $DB->set_field('course', 'timemodified', 0, ['id' => $SITE->id]); $DB->set_field('course', 'timemodified', 1, ['id' => $course1->id]); $DB->set_field('course', 'timemodified', 2, ['id' => $course2->id]); $DB->set_field('course', 'timemodified', 3, ['id' => $course3->id]); // Find the first block to use for a block context. $blockid = array_values($DB->get_records('block_instances', null, 'id', 'id', 0, 1))[0]->id; $blockcontext = \context_block::instance($blockid); // Check with block context - should be null. $this->assertNull($searcharea->get_document_recordset(0, $blockcontext)); // Check with user context - should be null. $this->setAdminUser(); $usercontext = \context_user::instance($USER->id); $this->assertNull($searcharea->get_document_recordset(0, $usercontext)); // Check with module context - should be null. $modcontext = \context_module::instance($forum->cmid); $this->assertNull($searcharea->get_document_recordset(0, $modcontext)); // Check with course context - should return specified course if timestamp allows. $coursecontext = \context_course::instance($course3->id); $results = self::recordset_to_ids($searcharea->get_document_recordset(3, $coursecontext)); $this->assertEquals([$course3->id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(4, $coursecontext)); $this->assertEquals([], $results); // Check with category context - should return course in categories and subcategories. $catcontext = \context_coursecat::instance($cat1->id); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $catcontext)); $this->assertEquals([$course1->id, $course2->id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(2, $catcontext)); $this->assertEquals([$course2->id], $results); // Check with system context and null - should return all these courses + site course. $systemcontext = \context_system::instance(); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $systemcontext)); $this->assertEquals([$SITE->id, $course1->id, $course2->id, $course3->id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, null)); $this->assertEquals([$SITE->id, $course1->id, $course2->id, $course3->id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(3, $systemcontext)); $this->assertEquals([$course3->id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(3, null)); $this->assertEquals([$course3->id], $results); } /** * Utility function to convert recordset to array of IDs for testing. * * @param moodle_recordset $rs Recordset to convert (and close) * @return array Array of IDs from records indexed by number (0, 1, 2, ...) */ protected static function recordset_to_ids(\moodle_recordset $rs) { $results = []; foreach ($rs as $rec) { $results[] = $rec->id; } $rs->close(); return $results; } /** * Document contents. * * @return void */ public function test_courses_document(): void { // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->coursesareaid); $this->assertInstanceOf('\core_course\search\course', $searcharea); $user = self::getDataGenerator()->create_user(); $course = self::getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($user->id, $course->id, 'teacher'); $doc = $searcharea->get_document($course); $this->assertInstanceOf('\core_search\document', $doc); $this->assertEquals($course->id, $doc->get('itemid')); $this->assertEquals($this->coursesareaid . '-' . $course->id, $doc->get('id')); $this->assertEquals($course->id, $doc->get('courseid')); $this->assertFalse($doc->is_set('userid')); $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid')); $this->assertEquals($course->fullname, $doc->get('title')); // Not nice. Applying \core_search\document::set line breaks clean up. $summary = preg_replace("/\s+/u", " ", content_to_text($course->summary, $course->summaryformat)); $this->assertEquals($summary, $doc->get('content')); $this->assertEquals($course->shortname, $doc->get('description1')); } /** * Document accesses. * * @return void */ public function test_courses_access(): void { $this->resetAfterTest(); // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->coursesareaid); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); $course1 = self::getDataGenerator()->create_course(); $course2 = self::getDataGenerator()->create_course(array('visible' => 0)); $course3 = self::getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student'); $this->getDataGenerator()->enrol_user($user1->id, $course2->id, 'teacher'); $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student'); $this->setUser($user1); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course3->id)); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123)); $this->setUser($user2); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course1->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($course2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course3->id)); } /** * Indexing section contents. */ public function test_section_indexing(): void { global $DB, $USER; // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->sectionareaid); $this->assertInstanceOf('\core_course\search\section', $searcharea); // Create some courses in categories, and a forum. $generator = $this->getDataGenerator(); $cat1 = $generator->create_category(); $cat2 = $generator->create_category(['parent' => $cat1->id]); $course1 = $generator->create_course(['category' => $cat1->id]); $course2 = $generator->create_course(['category' => $cat2->id]); $forum = $generator->create_module('forum', ['course' => $course1->id]); // Edit 2 sections on course 1 and one on course 2. $existing = $DB->get_record('course_sections', ['course' => $course1->id, 'section' => 2]); $course1section2id = $existing->id; $new = clone($existing); $new->name = 'Frogs'; course_update_section($course1->id, $existing, $new); $existing = $DB->get_record('course_sections', ['course' => $course1->id, 'section' => 3]); $course1section3id = $existing->id; $new = clone($existing); $new->summary = 'Frogs'; $new->summaryformat = FORMAT_HTML; course_update_section($course1->id, $existing, $new); $existing = $DB->get_record('course_sections', ['course' => $course2->id, 'section' => 1]); $course2section1id = $existing->id; $new = clone($existing); $new->summary = 'Frogs'; $new->summaryformat = FORMAT_HTML; course_update_section($course2->id, $existing, $new); // Bodge timemodified into a particular order. $DB->set_field('course_sections', 'timemodified', 1, ['id' => $course1section3id]); $DB->set_field('course_sections', 'timemodified', 2, ['id' => $course1section2id]); $DB->set_field('course_sections', 'timemodified', 3, ['id' => $course2section1id]); // All records. $results = self::recordset_to_ids($searcharea->get_document_recordset(0)); $this->assertEquals([$course1section3id, $course1section2id, $course2section1id], $results); // Records after time 2. $results = self::recordset_to_ids($searcharea->get_document_recordset(2)); $this->assertEquals([$course1section2id, $course2section1id], $results); // Records after time 10 (there aren't any). $results = self::recordset_to_ids($searcharea->get_document_recordset(10)); $this->assertEquals([], $results); // Find the first block to use for a block context. $blockid = array_values($DB->get_records('block_instances', null, 'id', 'id', 0, 1))[0]->id; $blockcontext = \context_block::instance($blockid); // Check with block context - should be null. $this->assertNull($searcharea->get_document_recordset(0, $blockcontext)); // Check with user context - should be null. $this->setAdminUser(); $usercontext = \context_user::instance($USER->id); $this->assertNull($searcharea->get_document_recordset(0, $usercontext)); // Check with module context - should be null. $modcontext = \context_module::instance($forum->cmid); $this->assertNull($searcharea->get_document_recordset(0, $modcontext)); // Check with course context - should return specific course entries. $coursecontext = \context_course::instance($course1->id); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $coursecontext)); $this->assertEquals([$course1section3id, $course1section2id], $results); $results = self::recordset_to_ids($searcharea->get_document_recordset(2, $coursecontext)); $this->assertEquals([$course1section2id], $results); // Check with category context - should return course in categories and subcategories. $catcontext = \context_coursecat::instance($cat1->id); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $catcontext)); $this->assertEquals([$course1section3id, $course1section2id, $course2section1id], $results); $catcontext = \context_coursecat::instance($cat2->id); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $catcontext)); $this->assertEquals([$course2section1id], $results); // Check with system context - should return everything (same as null, tested first). $systemcontext = \context_system::instance(); $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $systemcontext)); $this->assertEquals([$course1section3id, $course1section2id, $course2section1id], $results); } /** * Document contents for sections. */ public function test_section_document(): void { global $DB; $searcharea = \core_search\manager::get_search_area($this->sectionareaid); // Create a course. $generator = $this->getDataGenerator(); $course = $generator->create_course(); // Test with default title. $sectionrec = (object)['id' => 123, 'course' => $course->id, 'section' => 3, 'timemodified' => 456, 'summary' => 'Kermit', 'summaryformat' => FORMAT_HTML]; $doc = $searcharea->get_document($sectionrec); $this->assertInstanceOf('\core_search\document', $doc); $this->assertEquals(123, $doc->get('itemid')); $this->assertEquals($this->sectionareaid . '-123', $doc->get('id')); $this->assertEquals($course->id, $doc->get('courseid')); $this->assertFalse($doc->is_set('userid')); $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid')); $this->assertEquals('New section', $doc->get('title')); $this->assertEquals('Kermit', $doc->get('content')); // Test with user-set title. $DB->set_field('course_sections', 'name', 'Frogs', ['course' => $course->id, 'section' => 3]); rebuild_course_cache($course->id, true); $doc = $searcharea->get_document($sectionrec); $this->assertEquals('Frogs', $doc->get('title')); } /** * Document access for sections. */ public function test_section_access(): void { global $DB; $searcharea = \core_search\manager::get_search_area($this->sectionareaid); // Create a course. $generator = $this->getDataGenerator(); $course = $generator->create_course(); // Create 2 users - student and manager. Initially, student is not even enrolled. $student = $generator->create_user(); $manager = $generator->create_user(); $generator->enrol_user($manager->id, $course->id, 'manager'); // Two sections have content - one is hidden. $DB->set_field('course_sections', 'name', 'Frogs', ['course' => $course->id, 'section' => 1]); $DB->set_field('course_sections', 'name', 'Toads', ['course' => $course->id, 'section' => 2]); $DB->set_field('course_sections', 'visible', '0', ['course' => $course->id, 'section' => 2]); // Make the modified time be in order of sections. $DB->execute('UPDATE {course_sections} SET timemodified = section'); // Get the two document objects. $rs = $searcharea->get_document_recordset(); $documents = []; $index = 0; foreach ($rs as $rec) { $documents[$index++] = $searcharea->get_document($rec); } $this->assertCount(2, $documents); // Log in as admin and check access. $this->setAdminUser(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($documents[0]->get('itemid'))); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($documents[1]->get('itemid'))); // Log in as manager and check access. $this->setUser($manager); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($documents[0]->get('itemid'))); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($documents[1]->get('itemid'))); // Log in as student and check access - none yet. $this->setUser($student); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($documents[0]->get('itemid'))); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($documents[1]->get('itemid'))); // Enrol student - now they should get access but not to the hidden one. $generator->enrol_user($student->id, $course->id, 'student'); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($documents[0]->get('itemid'))); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($documents[1]->get('itemid'))); // Delete the course and check it returns deleted. delete_course($course, false); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($documents[0]->get('itemid'))); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($documents[1]->get('itemid'))); } /** * Indexing custom fields contents. * * @return void */ public function test_customfield_indexing(): void { // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->customfieldareaid); $this->assertInstanceOf('\core_course\search\customfield', $searcharea); // We need to be admin for custom fields creation. $this->setAdminUser(); // Custom fields. $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']); $customfield = ['shortname' => 'test', 'name' => 'Customfield', 'type' => 'text', 'categoryid' => $fieldcategory->get('id')]; $field = self::getDataGenerator()->create_custom_field($customfield); $course1data = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue1']]]; $course1 = self::getDataGenerator()->create_course($course1data); $course2data = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue2']]]; $course2 = self::getDataGenerator()->create_course($course2data); // All records. $recordset = $searcharea->get_recordset_by_timestamp(0); $this->assertTrue($recordset->valid()); $nrecords = 0; foreach ($recordset as $record) { $this->assertInstanceOf('stdClass', $record); $doc = $searcharea->get_document($record); $this->assertInstanceOf('\core_search\document', $doc); $nrecords++; } // If there would be an error/failure in the foreach above the recordset would be closed on shutdown. $recordset->close(); $this->assertEquals(2, $nrecords); // The +2 is to prevent race conditions. $recordset = $searcharea->get_recordset_by_timestamp(time() + 2); // No new records. $this->assertFalse($recordset->valid()); $recordset->close(); } /** * Document contents for custom fields. * * @return void */ public function test_customfield_document(): void { global $DB; // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->customfieldareaid); // We need to be admin for custom fields creation. $this->setAdminUser(); // Custom fields. $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']); $customfield = ['shortname' => 'test', 'name' => 'Customfield', 'type' => 'text', 'categoryid' => $fieldcategory->get('id')]; $field = self::getDataGenerator()->create_custom_field($customfield); $coursedata = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue1']]]; $course = self::getDataGenerator()->create_course($coursedata); // Retrieve data we need to compare with document instance. $record = $DB->get_record('customfield_data', ['instanceid' => $course->id]); $field = \core_customfield\field_controller::create($record->fieldid); $data = \core_customfield\data_controller::create(0, $record, $field); $doc = $searcharea->get_document($record); $this->assertInstanceOf('\core_search\document', $doc); $this->assertEquals('Customfield', $doc->get('title')); $this->assertEquals('Customvalue1', $doc->get('content')); $this->assertEquals($course->id, $doc->get('courseid')); $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid')); $this->assertEquals($course->id, $doc->get('courseid')); $this->assertFalse($doc->is_set('userid')); } /** * Document accesses for customfield area. */ public function test_customfield_access(): void { global $DB; $this->resetAfterTest(); // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->customfieldareaid); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); // Create our custom field. $customfieldcategory = $this->getDataGenerator()->create_custom_field_category([]); $customfield = $this->getDataGenerator()->create_custom_field([ 'categoryid' => $customfieldcategory->get('id'), 'type' => 'text', 'shortname' => 'myfield', ]); // Create courses, each containing our custom field. $course1 = $this->getDataGenerator()->create_course(['customfield_myfield' => 'Lionel']); $course2 = $this->getDataGenerator()->create_course(['customfield_myfield' => 'Rick', 'visible' => 0]); $course3 = $this->getDataGenerator()->create_course(['customfield_myfield' => 'Jack']); $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student'); $this->getDataGenerator()->enrol_user($user1->id, $course2->id, 'teacher'); $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student'); // Prevent users viewing course lists. $userrole = $DB->get_field('role', 'id', ['shortname' => 'user'], MUST_EXIST); assign_capability('moodle/category:viewcourselist', CAP_PREVENT, $userrole, \context_system::instance()->id, true); // The following assertions check whether each user can view the indexed customfield data record. $course1data = \core_customfield\data::get_record([ 'fieldid' => $customfield->get('id'), 'instanceid' => $course1->id, ]); $course2data = \core_customfield\data::get_record([ 'fieldid' => $customfield->get('id'), 'instanceid' => $course2->id, ]); $course3data = \core_customfield\data::get_record([ 'fieldid' => $customfield->get('id'), 'instanceid' => $course3->id, ]); // Admin user should see all present custom fields. $this->setAdminUser(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course1data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course2data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course3data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123)); // First user (teacher) should see all those in the courses they are teaching. $this->setUser($user1); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course1data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course2data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($course3data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123)); // Second user (student) should see all those in visible courses they are studying. $this->setUser($user2); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($course1data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($course2data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($course3data->get('id'))); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123)); } /** * Test document icon for course area. */ public function test_get_doc_icon_for_course_area(): void { $searcharea = \core_search\manager::get_search_area($this->coursesareaid); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $searcharea->get_doc_icon($document); $this->assertEquals('i/course', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } /** * Test document icon for section area. */ public function test_get_doc_icon_for_section_area(): void { $searcharea = \core_search\manager::get_search_area($this->sectionareaid); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $searcharea->get_doc_icon($document); $this->assertEquals('i/section', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } /** * Test assigned search categories. */ public function test_get_category_names(): void { $coursessearcharea = \core_search\manager::get_search_area($this->coursesareaid); $sectionsearcharea = \core_search\manager::get_search_area($this->sectionareaid); $customfieldssearcharea = \core_search\manager::get_search_area($this->customfieldareaid); $this->assertEquals(['core-courses'], $coursessearcharea->get_category_names()); $this->assertEquals(['core-course-content'], $sectionsearcharea->get_category_names()); $this->assertEquals(['core-course-content', 'core-courses'], $customfieldssearcharea->get_category_names()); } } customfield.php000064400000016274152266531570007621 0ustar00. /** * Search area for course custom fields. * * @package core_course * @copyright Toni Barbera * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\search; use core_course\customfield\course_handler; use core_customfield\data_controller; use core_customfield\field_controller; defined('MOODLE_INTERNAL') || die(); /** * Search area for course custom fields. * * @package core_course * @copyright Toni Barbera * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class customfield extends \core_search\base { /** * Custom fields are indexed at course context. * * @var array */ protected static $levels = [CONTEXT_COURSE]; /** * Returns recordset containing required data for indexing * course custom fields. * * @param int $modifiedfrom timestamp * @param \context|null $context Restriction context * @return \moodle_recordset|null Recordset or null if no change possible */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; list ($contextjoin, $contextparams) = $this->get_course_level_context_restriction_sql($context, 'c', SQL_PARAMS_NAMED); if ($contextjoin === null) { return null; } $fields = course_handler::create()->get_fields(); if (!$fields) { $fields = array(); } list($fieldsql, $fieldparam) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld', true, 0); // Restrict recordset to CONTEXT_COURSE (since we are implementing it to core_course\search). $sql = "SELECT d.* FROM {customfield_data} d JOIN {course} c ON c.id = d.instanceid JOIN {context} cnt ON cnt.instanceid = c.id $contextjoin WHERE d.timemodified >= :modifiedfrom AND cnt.contextlevel = :contextlevel AND d.fieldid $fieldsql ORDER BY d.timemodified ASC"; return $DB->get_recordset_sql($sql , array_merge($contextparams, ['modifiedfrom' => $modifiedfrom, 'contextlevel' => CONTEXT_COURSE], $fieldparam)); } /** * Returns the document associated with this section. * * @param \stdClass $record * @param array $options * @return \core_search\document|bool */ public function get_document($record, $options = array()) { global $PAGE; try { $context = \context_course::instance($record->instanceid); } catch (\moodle_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } $handler = course_handler::create(); $field = $handler->get_fields()[$record->fieldid]; $data = data_controller::create(0, $record, $field); // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', content_to_text($field->get('name'), false)); $doc->set('content', content_to_text($data->export_value(), FORMAT_HTML)); $doc->set('contextid', $context->id); $doc->set('courseid', $context->instanceid); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('modified', $record->timemodified); // Check if this document should be considered new. if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $record->timecreated)) { // If the document was created after the last index time, it must be new. $doc->set_is_new(true); } return $doc; } /** * Whether the user can access the document or not. * * @param int $id The custom field data ID * @return int */ public function check_access($id) { global $DB; $coursesql = ' SELECT c.* FROM {course} c JOIN {customfield_data} d ON d.instanceid = c.id WHERE d.id = :dataid'; // Verify both the course and data record still exist. $course = $DB->get_record_sql($coursesql, ['dataid' => $id]); if (!$course) { return \core_search\manager::ACCESS_DELETED; } // Check whether user is enrolled and the course is visible, or user can view it while hidden. $context = \context_course::instance($course->id); $userenrolled = is_enrolled($context) && ($course->visible || has_capability('moodle/course:viewhiddencourses', $context)); // Grant access if user is considered enrolled, or they can otherwise see the course info. if ($userenrolled || \core_course_category::can_view_course_info($course)) { return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DENIED; } /** * Link to the course. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } /** * Link to the course. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { return new \moodle_url('/course/view.php', array('id' => $doc->get('courseid'))); } /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return 'course'; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(\core_search\document $doc): \core_search\document_icon { return new \core_search\document_icon('i/customfield'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [ \core_search\manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT, \core_search\manager::SEARCH_AREA_CATEGORY_COURSES ]; } } section.php000064400000015772152266531570006751 0ustar00. /** * Search area for course sections (title and summary). * * @package core_course * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\search; defined('MOODLE_INTERNAL') || die(); /** * Search area for course sections (title and summary). * * Note this does not include the activities within the section, as these have their own search * areas. * * @package core_course * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class section extends \core_search\base { /** * Sections are indexed at course context. * * @var array */ protected static $levels = [CONTEXT_COURSE]; /** * Returns recordset containing required data for indexing course sections. * * @param int $modifiedfrom timestamp * @param \context|null $context Restriction context * @return \moodle_recordset|null Recordset or null if no change possible */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; list ($contextjoin, $contextparams) = $this->get_course_level_context_restriction_sql($context, 'c'); if ($contextjoin === null) { return null; } $comparetext = $DB->sql_compare_text('cs.summary', 1); return $DB->get_recordset_sql(" SELECT cs.id, cs.course, cs.section, cs.name, cs.summary, cs.summaryformat, cs.timemodified FROM {course_sections} cs JOIN {course} c ON c.id = cs.course $contextjoin WHERE cs.timemodified >= ? AND (cs.name != ? OR $comparetext != ?) ORDER BY cs.timemodified ASC", array_merge($contextparams, [$modifiedfrom, '', ''])); } /** * Returns the document associated with this section. * * @param \stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { global $CFG; require_once($CFG->dirroot . '/course/lib.php'); // Get the context, modinfo, and section. try { $context = \context_course::instance($record->course); } catch (\moodle_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } // Title - use default if none given. $title = get_section_name($record->course, $record->section); // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', content_to_text($title, false)); $doc->set('content', content_to_text($record->summary, $record->summaryformat)); $doc->set('contextid', $context->id); $doc->set('courseid', $record->course); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('modified', $record->timemodified); return $doc; } /** * Whether the user can access the section or not. * * @param int $id The course section id. * @return int One of the \core_search\manager:ACCESS_xx constants */ public function check_access($id) { global $DB; // Check we can get the section and the course modinfo. $sectionrec = $DB->get_record('course_sections', ['id' => $id], '*', IGNORE_MISSING); if (!$sectionrec) { return \core_search\manager::ACCESS_DELETED; } try { $modinfo = get_fast_modinfo($sectionrec->course); } catch (\moodle_exception $e) { return \core_search\manager::ACCESS_DELETED; } $section = $modinfo->get_section_info($sectionrec->section, IGNORE_MISSING); if (!$section) { return \core_search\manager::ACCESS_DELETED; } // Check access to course and that the section is visible to current user. if (can_access_course($modinfo->get_course()) && $section->uservisible) { return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DENIED; } /** * Gets a link to the section. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { global $DB; $section = $DB->get_field('course_sections', 'section', ['id' => $doc->get('itemid')], MUST_EXIST); $format = course_get_format($doc->get('courseid')); return $format->get_view_url($section); } /** * Gets a link to the section. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { return $this->get_doc_url($doc); } /** * Returns true to include summary files in the index. * * @return bool True */ public function uses_file_indexing() { return true; } /** * Return the file area that is used for summary files. * * @return array File area name */ public function get_search_fileareas() { return ['section']; } /** * Returns the moodle component name, as used in the files table. * * @return string Component name */ public function get_component_name() { return 'course'; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(\core_search\document $doc): \core_search\document_icon { return new \core_search\document_icon('i/section'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [\core_search\manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT]; } } course.php000064400000014065152266531570006577 0ustar00. /** * Search area for Moodle courses. * * @package core_course * @copyright 2016 Skylar Kelty * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\search; defined('MOODLE_INTERNAL') || die(); /** * Search area for Moodle courses. * * @package core_course * @copyright 2016 Skylar Kelty * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course extends \core_search\base { /** * The context levels the search implementation is working on. * * @var array */ protected static $levels = [CONTEXT_COURSE]; /** * Returns recordset containing required data for indexing courses. * * @param int $modifiedfrom timestamp * @param \context|null $context Restriction context * @return \moodle_recordset|null Recordset or null if no change possible */ public function get_document_recordset($modifiedfrom = 0, ?\context $context = null) { global $DB; list ($contextjoin, $contextparams) = $this->get_course_level_context_restriction_sql( $context, 'c'); if ($contextjoin === null) { return null; } return $DB->get_recordset_sql(" SELECT c.* FROM {course} c $contextjoin WHERE c.timemodified >= ? ORDER BY c.timemodified ASC", array_merge($contextparams, [$modifiedfrom])); } /** * Returns the document associated with this course. * * @param \stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { try { $context = \context_course::instance($record->id); } catch (\moodle_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', content_to_text($record->fullname, false)); $doc->set('content', content_to_text($record->summary, $record->summaryformat)); $doc->set('contextid', $context->id); $doc->set('courseid', $record->id); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('modified', $record->timemodified); $doc->set('description1', $record->shortname); // Check if this document should be considered new. if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timecreated) { // If the document was created after the last index time, it must be new. $doc->set_is_new(true); } return $doc; } /** * Whether the user can access the document or not. * * @param int $id The course instance id. * @return int */ public function check_access($id) { global $DB; $course = $DB->get_record('course', array('id' => $id)); if (!$course) { return \core_search\manager::ACCESS_DELETED; } if (\core_course_category::can_view_course_info($course)) { return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DENIED; } /** * Link to the course. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } /** * Link to the course. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { return new \moodle_url('/course/view.php', array('id' => $doc->get('courseid'))); } /** * Returns true if this area uses file indexing. * * @return bool */ public function uses_file_indexing() { return true; } /** * Return the context info required to index files for * this search area. * * Should be overridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array( 'overviewfiles', 'summary'// Fileareas. ); return $fileareas; } /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return 'course'; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(\core_search\document $doc): \core_search\document_icon { return new \core_search\document_icon('i/course'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [\core_search\manager::SEARCH_AREA_CATEGORY_COURSES]; } }