MDL-53247 search: allow search to be configured before enabled
[moodle.git] / search / classes / manager.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Search subsystem manager.
19  *
20  * @package   core_search
21  * @copyright Prateek Sachan {@link http://prateeksachan.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_search;
27 defined('MOODLE_INTERNAL') || die;
29 require_once($CFG->dirroot . '/lib/accesslib.php');
31 /**
32  * Search subsystem manager.
33  *
34  * @package   core_search
35  * @copyright Prateek Sachan {@link http://prateeksachan.com}
36  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class manager {
40     /**
41      * @var int Text contents.
42      */
43     const TYPE_TEXT = 1;
45     /**
46      * @var int User can not access the document.
47      */
48     const ACCESS_DENIED = 0;
50     /**
51      * @var int User can access the document.
52      */
53     const ACCESS_GRANTED = 1;
55     /**
56      * @var int The document was deleted.
57      */
58     const ACCESS_DELETED = 2;
60     /**
61      * @var int Maximum number of results that will be retrieved from the search engine.
62      */
63     const MAX_RESULTS = 100;
65     /**
66      * @var int Number of results per page.
67      */
68     const DISPLAY_RESULTS_PER_PAGE = 10;
70     /**
71      * @var \core_search\area\base[] Enabled search areas.
72      */
73     protected static $enabledsearchareas = null;
75     /**
76      * @var \core_search\area\base[] All system search areas.
77      */
78     protected static $allsearchareas = null;
80     /**
81      * @var \core_search\manager
82      */
83     protected static $instance = null;
85     /**
86      * @var \core_search\engine
87      */
88     protected $engine = null;
90     /**
91      * Constructor, use \core_search\manager::instance instead to get a class instance.
92      *
93      * @param \core_search\area\base The search engine to use
94      */
95     public function __construct($engine) {
96         $this->engine = $engine;
97     }
99     /**
100      * Returns an initialised \core_search instance.
101      *
102      * @throws \core_search\engine_exception
103      * @return \core_search\manager
104      */
105     public static function instance() {
106         global $CFG;
108         // One per request, this should be purged during testing.
109         if (static::$instance !== null) {
110             return static::$instance;
111         }
113         if (!$engine = static::search_engine_instance()) {
114             throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine);
115         }
117         if (!$engine->is_installed()) {
118             throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine);
119         }
121         $serverstatus = $engine->is_server_ready();
122         if ($serverstatus !== true) {
123             // Error message with no details as this is an exception that any user may find if the server crashes.
124             throw new \core_search\engine_exception('engineserverstatus', 'search');
125         }
127         static::$instance = new \core_search\manager($engine);
128         return static::$instance;
129     }
131     /**
132      * Returns whether global search is enabled or not.
133      *
134      * @return bool
135      */
136     public static function is_global_search_enabled() {
137         global $CFG;
138         return !empty($CFG->enableglobalsearch);
139     }
141     /**
142      * Returns an instance of the search engine.
143      *
144      * @return \core_search\engine
145      */
146     public static function search_engine_instance() {
147         global $CFG;
149         $classname = '\\search_' . $CFG->searchengine . '\\engine';
150         if (!class_exists($classname)) {
151             return false;
152         }
154         return new $classname();
155     }
157     /**
158      * Returns the search engine.
159      *
160      * @return \core_search\engine
161      */
162     public function get_engine() {
163         return $this->engine;
164     }
166     /**
167      * Returns a search area class name.
168      *
169      * @param string $areaid
170      * @return string
171      */
172     protected static function get_area_classname($areaid) {
173         list($componentname, $areaname) = static::extract_areaid_parts($areaid);
174         return '\\' . $componentname . '\\search\\' . $areaname;
175     }
177     /**
178      * Returns a new area search indexer instance.
179      *
180      * @param string $areaid
181      * @return \core_search\area\base|bool False if the area is not available.
182      */
183     public static function get_search_area($areaid) {
185         // Try both caches, it does not matter where it comes from.
186         if (!empty(static::$allsearchareas[$areaid])) {
187             return static::$allsearchareas[$areaid];
188         }
189         if (!empty(static::$enabledsearchareas[$areaid])) {
190             return static::$enabledsearchareas[$areaid];
191         }
193         $classname = static::get_area_classname($areaid);
194         if (class_exists($classname)) {
195             return new $classname();
196         }
198         return false;
199     }
201     /**
202      * Return the list of available search areas.
203      *
204      * @param bool $enabled Return only the enabled ones.
205      * @return \core_search\area\base[]
206      */
207     public static function get_search_areas_list($enabled = false) {
209         // Two different arrays, we don't expect these arrays to be big.
210         if (!$enabled && static::$allsearchareas !== null) {
211             return static::$allsearchareas;
212         } else if ($enabled && static::$enabledsearchareas !== null) {
213             return static::$enabledsearchareas;
214         }
216         $searchareas = array();
218         $plugintypes = \core_component::get_plugin_types();
219         foreach ($plugintypes as $plugintype => $unused) {
220             $plugins = \core_component::get_plugin_list($plugintype);
221             foreach ($plugins as $pluginname => $pluginfullpath) {
223                 $componentname = $plugintype . '_' . $pluginname;
224                 $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
225                 foreach ($searchclasses as $classname => $classpath) {
226                     $areaname = substr(strrchr($classname, '\\'), 1);
227                     $areaid = static::generate_areaid($componentname, $areaname);
228                     $searchclass = new $classname();
229                     if (!$enabled || ($enabled && $searchclass->is_enabled())) {
230                         $searchareas[$areaid] = $searchclass;
231                     }
232                 }
233             }
234         }
236         $subsystems = \core_component::get_core_subsystems();
237         foreach ($subsystems as $subsystemname => $subsystempath) {
238             $componentname = 'core_' . $subsystemname;
239             $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
241             foreach ($searchclasses as $classname => $classpath) {
242                 $areaname = substr(strrchr($classname, '\\'), 1);
243                 $areaid = static::generate_areaid($componentname, $areaname);
244                 $searchclass = new $classname();
245                 if (!$enabled || ($enabled && $searchclass->is_enabled())) {
246                     $searchareas[$areaid] = $searchclass;
247                 }
248             }
249         }
251         // Cache results.
252         if ($enabled) {
253             static::$enabledsearchareas = $searchareas;
254         } else {
255             static::$allsearchareas = $searchareas;
256         }
258         return $searchareas;
259     }
261     /**
262      * Clears all static caches.
263      *
264      * @return void
265      */
266     public static function clear_static() {
268         static::$enabledsearchareas = null;
269         static::$allsearchareas = null;
270         static::$instance = null;
271     }
273     /**
274      * Generates an area id from the componentname and the area name.
275      *
276      * There should not be any naming conflict as the area name is the
277      * class name in component/classes/search/.
278      *
279      * @param string $componentname
280      * @param string $areaname
281      * @return void
282      */
283     public static function generate_areaid($componentname, $areaname) {
284         return $componentname . '-' . $areaname;
285     }
287     /**
288      * Returns all areaid string components (component name and area name).
289      *
290      * @param string $areaid
291      * @return array Component name (Frankenstyle) and area name (search area class name)
292      */
293     public static function extract_areaid_parts($areaid) {
294         return explode('-', $areaid);
295     }
297     /**
298      * Returns the contexts the user can access.
299      *
300      * The returned value is a multidimensional array because some search engines can group
301      * information and there will be a performance benefit on passing only some contexts
302      * instead of the whole context array set.
303      *
304      * @return bool|array Indexed by area identifier (component + area name). Returns true if the user can see everything.
305      */
306     protected function get_areas_user_accesses() {
307         global $CFG, $USER;
309         // All results for admins. Eventually we could add a new capability for managers.
310         if (is_siteadmin()) {
311             return true;
312         }
314         $areasbylevel = array();
316         // Split areas by context level so we only iterate only once through courses and cms.
317         $searchareas = static::get_search_areas_list(true);
318         foreach ($searchareas as $areaid => $unused) {
319             $classname = static::get_area_classname($areaid);
320             $searcharea = new $classname();
321             foreach ($classname::get_levels() as $level) {
322                 $areasbylevel[$level][$areaid] = $searcharea;
323             }
324         }
326         // This will store area - allowed contexts relations.
327         $areascontexts = array();
329         if (!empty($areasbylevel[CONTEXT_SYSTEM])) {
330             // We add system context to all search areas working at this level. Here each area is fully responsible of
331             // the access control as we can not automate much, we can not even check guest access as some areas might
332             // want to allow guests to retrieve data from them.
334             $systemcontextid = \context_system::instance()->id;
335             foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) {
336                 $areascontexts[$areaid][] = $systemcontextid;
337             }
338         }
340         // Get the courses where the current user has access.
341         $courses = enrol_get_my_courses(array('id', 'cacherev'));
342         $courses[SITEID] = get_course(SITEID);
343         $site = \course_modinfo::instance(SITEID);
344         foreach ($courses as $course) {
346             // Info about the course modules.
347             $modinfo = get_fast_modinfo($course);
349             if (!empty($areasbylevel[CONTEXT_COURSE])) {
350                 // Add the course contexts the user can view.
352                 $coursecontext = \context_course::instance($course->id);
353                 foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) {
354                     if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
355                         $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id;
356                     }
357                 }
358             }
360             if (!empty($areasbylevel[CONTEXT_MODULE])) {
361                 // Add the module contexts the user can view (cm_info->uservisible).
363                 foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) {
365                     // Removing the plugintype 'mod_' prefix.
366                     $modulename = substr($searchclass->get_component_name(), 4);
368                     $modinstances = $modinfo->get_instances_of($modulename);
369                     foreach ($modinstances as $modinstance) {
370                         if ($modinstance->uservisible) {
371                             $areascontexts[$areaid][$modinstance->context->id] = $modinstance->context->id;
372                         }
373                     }
374                 }
375             }
376         }
378         return $areascontexts;
379     }
381     /**
382      * Returns documents from the engine based on the data provided.
383      *
384      * This function does not perform any kind of security checking, the caller code
385      * should check that the current user have moodle/search:query capability.
386      *
387      * It might return the results from the cache instead.
388      *
389      * @param stdClass $formdata
390      * @return \core_search\document[]
391      */
392     public function search(\stdClass $formdata) {
394         $cache = \cache::make('core', 'search_results');
396         // Generate a string from all query filters
397         // Not including $areascontext here, being a user cache it is not needed.
398         $querykey = $this->generate_query_key($formdata);
400         // Look for cached results before executing it.
401         if ($results = $cache->get($querykey)) {
402             return $results;
403         }
405         // Clears previous query errors.
406         $this->engine->clear_query_error();
408         $areascontexts = $this->get_areas_user_accesses();
409         if (!$areascontexts) {
410             // User can not access any context.
411             $docs = array();
412         } else {
413             $docs = $this->engine->execute_query($formdata, $areascontexts);
414         }
416         // Cache results.
417         $cache->set($querykey, $docs);
419         return $docs;
420     }
422     /**
423      * We generate the key ourselves so MUC knows that it contains simplekeys.
424      *
425      * @param stdClass $formdata
426      * @return string
427      */
428     protected function generate_query_key($formdata) {
430         // Empty values by default (although q should always have a value).
431         $fields = array('q', 'title', 'areaid', 'timestart', 'timeend', 'page');
433         // Just in this function scope.
434         $params = clone $formdata;
435         foreach ($fields as $field) {
436             if (empty($params->{$field})) {
437                 $params->{$field} = '';
438             }
439         }
441         // Although it is not likely, we prevent cache hits if available search areas change during the session.
442         $enabledareas = implode('-', array_keys(static::get_search_areas_list(true)));
444         return md5($params->q . 'title=' . $params->title . 'areaid=' . $params->areaid .
445             'timestart=' . $params->timestart . 'timeend=' . $params->timeend . 'page=' . $params->page .
446             $enabledareas);
447     }
449     /**
450      * Merge separate index segments into one.
451      */
452     public function optimize_index() {
453         $this->engine->optimize();
454     }
456     /**
457      * Index all documents.
458      *
459      * @param bool $fullindex Whether we should reindex everything or not.
460      * @throws \moodle_exception
461      * @return bool Whether there was any updated document or not.
462      */
463     public function index($fullindex = false) {
464         global $CFG;
466         // Unlimited time.
467         \core_php_time_limit::raise();
469         $anyupdate = false;
471         $searchareas = $this->get_search_areas_list(true);
472         foreach ($searchareas as $areaid => $searcharea) {
474             if (CLI_SCRIPT && !PHPUNIT_TEST) {
475                 mtrace('Processing ' . $searcharea->get_visible_name() . ' area');
476             }
478             $indexingstart = time();
480             // This is used to store this component config.
481             list($componentconfigname, $varname) = $searcharea->get_config_var_name();
483             $numrecords = 0;
484             $numdocs = 0;
485             $numdocsignored = 0;
486             $lastindexeddoc = 0;
488             if ($fullindex === true) {
489                 $prevtimestart = 0;
490             } else {
491                 $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
492             }
494             // Getting the recordset from the area.
495             $recordset = $searcharea->get_recordset_by_timestamp($prevtimestart);
497             // Pass get_document as callback.
498             $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'));
499             foreach ($iterator as $document) {
501                 if (!$document instanceof \core_search\document) {
502                     continue;
503                 }
505                 $docdata = $document->export_for_engine();
506                 switch ($docdata['type']) {
507                     case static::TYPE_TEXT:
508                         $this->engine->add_document($docdata);
509                         $numdocs++;
510                         break;
511                     default:
512                         $numdocsignored++;
513                         $iterator->close();
514                         throw new \moodle_exception('doctypenotsupported', 'search');
515                 }
517                 $lastindexeddoc = $document->get('modified');
518                 $numrecords++;
519             }
521             if ($numdocs > 0) {
522                 $anyupdate = true;
524                 // Commit all remaining documents.
525                 $this->engine->commit();
527                 if (CLI_SCRIPT && !PHPUNIT_TEST) {
528                     mtrace('Processed ' . $numrecords . ' records containing ' . $numdocs . ' documents for ' .
529                         $searcharea->get_visible_name() . ' area. Commits completed.');
530                 }
531             } else if (CLI_SCRIPT && !PHPUNIT_TEST) {
532                 mtrace('No new documents to index for ' . $searcharea->get_visible_name() . ' area.');
533             }
535             // Store last index run once documents have been commited to the search engine.
536             set_config($varname . '_indexingstart', $indexingstart, $componentconfigname);
537             set_config($varname . '_indexingend', time(), $componentconfigname);
538             set_config($varname . '_docsignored', $numdocsignored, $componentconfigname);
539             set_config($varname . '_docsprocessed', $numdocs, $componentconfigname);
540             set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname);
541             if ($lastindexeddoc > 0) {
542                 set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname);
543             }
544         }
546         if ($anyupdate) {
547             $event = \core\event\search_indexed::create(
548                     array('context' => \context_system::instance()));
549             $event->trigger();
550         }
552         return $anyupdate;
553     }
555     /**
556      * Resets areas config.
557      *
558      * @throws \moodle_exception
559      * @param string $areaid
560      * @return void
561      */
562     public function reset_config($areaid = false) {
564         if (!empty($areaid)) {
565             $searchareas = array();
566             if (!$searchareas[$areaid] = static::get_search_area($areaid)) {
567                 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid);
568             }
569         } else {
570             // Only the enabled ones.
571             $searchareas = static::get_search_areas_list(true);
572         }
574         foreach ($searchareas as $searcharea) {
575             list($componentname, $varname) = $searcharea->get_config_var_name();
576             $config = $searcharea->get_config();
578             foreach ($config as $key => $value) {
579                 // We reset them all but the enable/disabled one.
580                 if ($key !== $varname . '_enabled') {
581                     set_config($key, 0, $componentname);
582                 }
583             }
584         }
585     }
587     /**
588      * Deletes an area's documents or all areas documents.
589      *
590      * @param string $areaid The area id or false for all
591      * @return void
592      */
593     public function delete_index($areaid = false) {
594         if (!empty($areaid)) {
595             $this->engine->delete($areaid);
596             $this->reset_config($areaid);
597         } else {
598             $this->engine->delete();
599             $this->reset_config();
600         }
601         $this->engine->commit();
602     }
604     /**
605      * Deletes index by id.
606      *
607      * @param int Solr Document string $id
608      */
609     public function delete_index_by_id($id) {
610         $this->engine->delete_by_id($id);
611         $this->engine->commit();
612     }
614     /**
615      * Returns search areas configuration.
616      *
617      * @param \core_search\area\base[] $searchareas
618      * @return \stdClass[] $configsettings
619      */
620     public function get_areas_config($searchareas) {
622         $allconfigs = get_config('search');
623         $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored', 'docsprocessed', 'recordsprocessed');
625         $configsettings =  array();
626         foreach ($searchareas as $searcharea) {
628             $areaid = $searcharea->get_area_id();
630             $configsettings[$areaid] = new \stdClass();
631             list($componentname, $varname) = $searcharea->get_config_var_name();
633             if (!$searcharea->is_enabled()) {
634                 // We delete all indexed data on disable so no info.
635                 foreach ($vars as $var) {
636                     $configsettings[$areaid]->{$var} = 0;
637                 }
638             } else {
639                 foreach ($vars as $var) {
640                     $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var);
641                 }
642             }
644             // Formatting the time.
645             if (!empty($configsettings[$areaid]->lastindexrun)) {
646                 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun);
647             } else {
648                 $configsettings[$areaid]->lastindexrun = get_string('never');
649             }
650         }
651         return $configsettings;
652     }