Merge branch 'MDL-68334-master' of git://github.com/lucaboesch/moodle
[moodle.git] / search / classes / engine.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  * Base class for search engines.
19  *
20  * All search engines must extend this class.
21  *
22  * @package   core_search
23  * @copyright 2015 Daniel Neis
24  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
27 namespace core_search;
29 defined('MOODLE_INTERNAL') || die();
31 /**
32  * Base class for search engines.
33  *
34  * All search engines must extend this class.
35  *
36  * @package   core_search
37  * @copyright 2015 Daniel Neis
38  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 abstract class engine {
42     /**
43      * The search engine configuration.
44      *
45      * @var \stdClass
46      */
47     protected $config = null;
49     /**
50      * Last executed query error, if there was any.
51      * @var string
52      */
53     protected $queryerror = null;
55     /**
56      * @var array Internal cache.
57      */
58     protected $cachedareas = array();
60     /**
61      * @var array Internal cache.
62      */
63     protected $cachedcourses = array();
65     /**
66      * User data required to show their fullnames. Indexed by userid.
67      *
68      * @var \stdClass[]
69      */
70     protected static $cachedusers = array();
72     /**
73      * @var string Frankenstyle plugin name.
74      */
75     protected $pluginname = null;
77     /**
78      * @var bool If true, should skip schema validity check when checking the search engine is ready
79      */
80     protected $skipschemacheck = false;
82     /**
83      * Initialises the search engine configuration.
84      *
85      * Search engine availability should be checked separately.
86      *
87      * The alternate configuration option is only used to construct a special second copy of the
88      * search engine object, as described in {@see has_alternate_configuration}.
89      *
90      * @param bool $alternateconfiguration If true, use alternate configuration settings
91      * @return void
92      */
93     public function __construct(bool $alternateconfiguration = false) {
95         $classname = get_class($this);
96         if (strpos($classname, '\\') === false) {
97             throw new \coding_exception('"' . $classname . '" class should specify its component namespace and it should be named engine.');
98         } else if (strpos($classname, '_') === false) {
99             throw new \coding_exception('"' . $classname . '" class namespace should be its frankenstyle name');
100         }
102         // This is search_xxxx config.
103         $this->pluginname = substr($classname, 0, strpos($classname, '\\'));
104         if ($config = get_config($this->pluginname)) {
105             $this->config = $config;
106         } else {
107             $this->config = new stdClass();
108         }
110         // For alternate configuration, automatically replace normal configuration values with
111         // those beginning with 'alternate'.
112         if ($alternateconfiguration) {
113             foreach ((array)$this->config as $key => $value) {
114                 if (preg_match('~^alternate(.*)$~', $key, $matches)) {
115                     $this->config->{$matches[1]} = $value;
116                 }
117             }
118         }
120         // Flag just in case engine needs to know it is using the alternate configuration.
121         $this->config->alternateconfiguration = $alternateconfiguration;
122     }
124     /**
125      * Returns a course instance checking internal caching.
126      *
127      * @param int $courseid
128      * @return stdClass
129      */
130     protected function get_course($courseid) {
131         if (!empty($this->cachedcourses[$courseid])) {
132             return $this->cachedcourses[$courseid];
133         }
135         // No need to clone, only read.
136         $this->cachedcourses[$courseid] = get_course($courseid, false);
138         return $this->cachedcourses[$courseid];
139     }
141     /**
142      * Returns user data checking the internal static cache.
143      *
144      * Including here the minimum required user information as this may grow big.
145      *
146      * @param int $userid
147      * @return stdClass
148      */
149     public function get_user($userid) {
150         global $DB;
152         if (empty(self::$cachedusers[$userid])) {
153             $fields = get_all_user_name_fields(true);
154             self::$cachedusers[$userid] = $DB->get_record('user', array('id' => $userid), 'id, ' . $fields);
155         }
156         return self::$cachedusers[$userid];
157     }
159     /**
160      * Clears the users cache.
161      *
162      * @return null
163      */
164     public static function clear_users_cache() {
165         self::$cachedusers = [];
166     }
168     /**
169      * Returns a search instance of the specified area checking internal caching.
170      *
171      * @param string $areaid Area id
172      * @return \core_search\base
173      */
174     protected function get_search_area($areaid) {
176         if (isset($this->cachedareas[$areaid]) && $this->cachedareas[$areaid] === false) {
177             // We already checked that area and it is not available.
178             return false;
179         }
181         if (!isset($this->cachedareas[$areaid])) {
182             // First result that matches this area.
184             $this->cachedareas[$areaid] = \core_search\manager::get_search_area($areaid);
185             if ($this->cachedareas[$areaid] === false) {
186                 // The area does not exist or it is not available any more.
188                 $this->cachedareas[$areaid] = false;
189                 return false;
190             }
192             if (!$this->cachedareas[$areaid]->is_enabled()) {
193                 // We skip the area if it is not enabled.
195                 // Marking it as false so next time we don' need to check it again.
196                 $this->cachedareas[$areaid] = false;
198                 return false;
199             }
200         }
202         return $this->cachedareas[$areaid];
203     }
205     /**
206      * Returns a document instance prepared to be rendered.
207      *
208      * @param \core_search\base $searcharea
209      * @param array $docdata
210      * @return \core_search\document
211      */
212     protected function to_document(\core_search\base $searcharea, $docdata) {
214         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($docdata['areaid']);
215         $doc = \core_search\document_factory::instance($docdata['itemid'], $componentname, $areaname, $this);
216         $doc->set_data_from_engine($docdata);
217         $doc->set_doc_url($searcharea->get_doc_url($doc));
218         $doc->set_context_url($searcharea->get_context_url($doc));
219         $doc->set_doc_icon($searcharea->get_doc_icon($doc));
221         // Uses the internal caches to get required data needed to render the document later.
222         $course = $this->get_course($doc->get('courseid'));
223         $doc->set_extra('coursefullname', $course->fullname);
225         if ($doc->is_set('userid')) {
226             $user = $this->get_user($doc->get('userid'));
227             $doc->set_extra('userfullname', fullname($user));
228         }
230         return $doc;
231     }
233     /**
234      * Loop through given iterator of search documents
235      * and and have the search engine back end add them
236      * to the index.
237      *
238      * @param \iterator $iterator the iterator of documents to index
239      * @param base $searcharea the area for the documents to index
240      * @param array $options document indexing options
241      * @return array Processed document counts
242      */
243     public function add_documents($iterator, $searcharea, $options) {
244         $numrecords = 0;
245         $numdocs = 0;
246         $numdocsignored = 0;
247         $numbatches = 0;
248         $lastindexeddoc = 0;
249         $firstindexeddoc = 0;
250         $partial = false;
251         $lastprogress = manager::get_current_time();
253         $batchmode = $this->supports_add_document_batch();
254         $currentbatch = [];
256         foreach ($iterator as $document) {
257             // Stop if we have exceeded the time limit (and there are still more items). Always
258             // do at least one second's worth of documents otherwise it will never make progress.
259             if ($lastindexeddoc !== $firstindexeddoc &&
260                     !empty($options['stopat']) && manager::get_current_time() >= $options['stopat']) {
261                 $partial = true;
262                 break;
263             }
265             if (!$document instanceof \core_search\document) {
266                 continue;
267             }
269             if (isset($options['lastindexedtime']) && $options['lastindexedtime'] == 0) {
270                 // If we have never indexed this area before, it must be new.
271                 $document->set_is_new(true);
272             }
274             if ($options['indexfiles']) {
275                 // Attach files if we are indexing.
276                 $searcharea->attach_files($document);
277             }
279             if ($batchmode && strlen($document->get('content')) <= $this->get_batch_max_content()) {
280                 $currentbatch[] = $document;
281                 if (count($currentbatch) >= $this->get_batch_max_documents()) {
282                     [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']);
283                     $numdocs += $processed;
284                     $numdocsignored += $failed;
285                     $numbatches += $batches;
286                     $currentbatch = [];
287                 }
288             } else {
289                 if ($this->add_document($document, $options['indexfiles'])) {
290                     $numdocs++;
291                 } else {
292                     $numdocsignored++;
293                 }
294                 $numbatches++;
295             }
297             $lastindexeddoc = $document->get('modified');
298             if (!$firstindexeddoc) {
299                 $firstindexeddoc = $lastindexeddoc;
300             }
301             $numrecords++;
303             // If indexing the area takes a long time, periodically output progress information.
304             if (isset($options['progress'])) {
305                 $now = manager::get_current_time();
306                 if ($now - $lastprogress >= manager::DISPLAY_INDEXING_PROGRESS_EVERY) {
307                     $lastprogress = $now;
308                     // The first date format is the same used in cron_trace_time_and_memory().
309                     $options['progress']->output(date('H:i:s', $now) . ': Done to ' . userdate(
310                             $lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')), 1);
311                 }
312             }
313         }
315         // Add remaining documents from batch.
316         if ($batchmode && $currentbatch) {
317             [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']);
318             $numdocs += $processed;
319             $numdocsignored += $failed;
320             $numbatches += $batches;
321         }
323         return [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $numbatches];
324     }
326     /**
327      * Returns the plugin name.
328      *
329      * @return string Frankenstyle plugin name.
330      */
331     public function get_plugin_name() {
332         return $this->pluginname;
333     }
335     /**
336      * Gets the document class used by this search engine.
337      *
338      * Search engines can overwrite \core_search\document with \search_ENGINENAME\document class.
339      *
340      * Looks for a document class in the current search engine namespace, falling back to \core_search\document.
342      * Publicly available because search areas do not have access to the engine details,
343      * \core_search\document_factory accesses this function.
344      *
345      * @return string
346      */
347     public function get_document_classname() {
348         $classname = $this->pluginname . '\\document';
349         if (!class_exists($classname)) {
350             $classname = '\\core_search\\document';
351         }
352         return $classname;
353     }
355     /**
356      * Run any pre-indexing operations.
357      *
358      * Should be overwritten if the search engine needs to do any pre index preparation.
359      *
360      * @param bool $fullindex True if a full index will be performed
361      * @return void
362      */
363     public function index_starting($fullindex = false) {
364         // Nothing by default.
365     }
367     /**
368      * Run any post indexing operations.
369      *
370      * Should be overwritten if the search engine needs to do any post index cleanup.
371      *
372      * @param int $numdocs The number of documents that were added to the index
373      * @param bool $fullindex True if a full index was performed
374      * @return void
375      */
376     public function index_complete($numdocs = 0, $fullindex = false) {
377         // Nothing by default.
378     }
380     /**
381      * Do anything that may need to be done before an area is indexed.
382      *
383      * @param \core_search\base $searcharea The search area that was complete
384      * @param bool $fullindex True if a full index is being performed
385      * @return void
386      */
387     public function area_index_starting($searcharea, $fullindex = false) {
388         // Nothing by default.
389     }
391     /**
392      * Do any area cleanup needed, and do anything to confirm contents.
393      *
394      * Return false to prevent the search area completed time and stats from being updated.
395      *
396      * @param \core_search\base $searcharea The search area that was complete
397      * @param int $numdocs The number of documents that were added to the index
398      * @param bool $fullindex True if a full index is being performed
399      * @return bool True means that data is considered indexed
400      */
401     public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) {
402         return true;
403     }
405     /**
406      * Optimizes the search engine.
407      *
408      * Should be overwritten if the search engine can optimize its contents.
409      *
410      * @return void
411      */
412     public function optimize() {
413         // Nothing by default.
414         mtrace('The ' . get_string('pluginname', $this->get_plugin_name()) .
415                 ' search engine does not require automatic optimization.');
416     }
418     /**
419      * Does the system satisfy all the requirements.
420      *
421      * Should be overwritten if the search engine has any system dependencies
422      * that needs to be checked.
423      *
424      * @return bool
425      */
426     public function is_installed() {
427         return true;
428     }
430     /**
431      * Returns any error reported by the search engine when executing the provided query.
432      *
433      * It should be called from static::execute_query when an exception is triggered.
434      *
435      * @return string
436      */
437     public function get_query_error() {
438         return $this->queryerror;
439     }
441     /**
442      * Returns the total number of documents available for the most recent call to execute_query.
443      *
444      * This can be an estimate, but should get more accurate the higher the limited passed to execute_query is.
445      * To do that, the engine can use (actual result returned count + count of unchecked documents), or
446      * (total possible docs - docs that have been checked and rejected).
447      *
448      * Engine can limit to manager::MAX_RESULTS if there is cost to determining more.
449      * If this cannot be computed in a reasonable way, manager::MAX_RESULTS may be returned.
450      *
451      * @return int
452      */
453     abstract public function get_query_total_count();
455     /**
456      * Return true if file indexing is supported and enabled. False otherwise.
457      *
458      * @return bool
459      */
460     public function file_indexing_enabled() {
461         return false;
462     }
464     /**
465      * Clears the current query error value.
466      *
467      * @return void
468      */
469     public function clear_query_error() {
470         $this->queryerror = null;
471     }
473     /**
474      * Is the server ready to use?
475      *
476      * This should also check that the search engine configuration is ok.
477      *
478      * If the function $this->should_skip_schema_check() returns true, then this function may leave
479      * out time-consuming checks that the schema is valid. (This allows for improved performance on
480      * critical pages such as the main search form.)
481      *
482      * @return true|string Returns true if all good or an error string.
483      */
484     abstract function is_server_ready();
486     /**
487      * Tells the search engine to skip any time-consuming checks that it might do as part of the
488      * is_server_ready function, and only carry out a basic check that it can contact the server.
489      *
490      * This setting is not remembered and applies only to the current request.
491      *
492      * @since Moodle 3.5
493      * @param bool $skip True to skip the checks, false to start checking again
494      */
495     public function skip_schema_check($skip = true) {
496         $this->skipschemacheck = $skip;
497     }
499     /**
500      * For use by subclasses. The engine can call this inside is_server_ready to check whether it
501      * should skip time-consuming schema checks.
502      *
503      * @since Moodle 3.5
504      * @return bool True if schema checks should be skipped
505      */
506     protected function should_skip_schema_check() {
507         return $this->skipschemacheck;
508     }
510     /**
511      * Adds a document to the search engine.
512      *
513      * @param document $document
514      * @param bool     $fileindexing True if file indexing is to be used
515      * @return bool    False if the file was skipped or failed, true on success
516      */
517     abstract function add_document($document, $fileindexing = false);
519     /**
520      * Adds multiple documents to the search engine.
521      *
522      * It should return the number successfully processed, and the number of batches they were
523      * processed in (for example if you add 100 documents and there is an error processing one of
524      * those documents, and it took 4 batches, it would return [99, 1, 4]).
525      *
526      * If the engine implements this, it should return true to {@see supports_add_document_batch}.
527      *
528      * The system will only call this function with up to {@see get_batch_max_documents} documents,
529      * and each document in the batch will have content no larger than specified by
530      * {@see get_batch_max_content}.
531      *
532      * @param document[] $documents Documents to add
533      * @param bool $fileindexing True if file indexing is to be used
534      * @return int[] Array of three elements, successfully processed, failed processed, batch count
535      */
536     public function add_document_batch(array $documents, bool $fileindexing = false): array {
537         throw new \coding_exception('add_document_batch not supported by this engine');
538     }
540     /**
541      * Executes the query on the engine.
542      *
543      * Implementations of this function should check user context array to limit the results to contexts where the
544      * user have access. They should also limit the owneruserid field to manger::NO_OWNER_ID or the current user's id.
545      * Engines must use area->check_access() to confirm user access.
546      *
547      * Engines should reasonably attempt to fill up to limit with valid results if they are available.
548      *
549      * The $filters object may include the following fields (optional except q):
550      * - q: value of main search field; results should include this text
551      * - title: if included, title must match this search
552      * - areaids: array of search area id strings (only these areas will be searched)
553      * - courseids: array of course ids (only these courses will be searched)
554      * - groupids: array of group ids (only results specifically from these groupids will be
555      *   searched) - this option will be ignored if the search engine doesn't support groups
556      *
557      * The $accessinfo parameter has two different values (for historical compatibility). If the
558      * engine returns false to supports_group_filtering then it is an array of user contexts, or
559      * true if the user can access all contexts. (This parameter used to be called $usercontexts.)
560      * If the engine returns true to supports_group_filtering then it will be an object containing
561      * these fields:
562      * - everything (true if admin is searching with no restrictions)
563      * - usercontexts (same as above)
564      * - separategroupscontexts (array of context ids where separate groups are used)
565      * - visiblegroupscontextsareas (array of subset of those where some areas use visible groups)
566      * - usergroups (array of relevant group ids that user belongs to)
567      *
568      * The engine should apply group restrictions to those contexts listed in the
569      * 'separategroupscontexts' array. In these contexts, it shouled only include results if the
570      * groupid is not set, or if the groupid matches one of the values in USER_GROUPS array, or
571      * if the search area is one of those listed in 'visiblegroupscontextsareas' for that context.
572      *
573      * @param \stdClass $filters Query and filters to apply.
574      * @param \stdClass $accessinfo Information about the contexts the user can access
575      * @param  int      $limit The maximum number of results to return. If empty, limit to manager::MAX_RESULTS.
576      * @return \core_search\document[] Results or false if no results
577      */
578     public abstract function execute_query($filters, $accessinfo, $limit = 0);
580     /**
581      * Delete all documents.
582      *
583      * @param string $areaid To filter by area
584      * @return void
585      */
586     abstract function delete($areaid = null);
588     /**
589      * Deletes information related to a specific context id. This should be used when the context
590      * itself is deleted from Moodle.
591      *
592      * This only deletes information for the specified context - not for any child contexts.
593      *
594      * This function is optional; if not supported it will return false and the information will
595      * not be deleted from the search index.
596      *
597      * If an engine implements this function it should also implement delete_index_for_course;
598      * otherwise, nothing will be deleted when users delete an entire course at once.
599      *
600      * @param int $oldcontextid ID of context that has been deleted
601      * @return bool True if implemented
602      * @throws \core_search\engine_exception Engines may throw this exception for any problem
603      */
604     public function delete_index_for_context(int $oldcontextid) {
605         return false;
606     }
608     /**
609      * Deletes information related to a specific course id. This should be used when the course
610      * itself is deleted from Moodle.
611      *
612      * This deletes all information relating to that course from the index, including all child
613      * contexts.
614      *
615      * This function is optional; if not supported it will return false and the information will
616      * not be deleted from the search index.
617      *
618      * If an engine implements this function then, ideally, it should also implement
619      * delete_index_for_context so that deletion of single activities/blocks also works.
620      *
621      * @param int $oldcourseid ID of course that has been deleted
622      * @return bool True if implemented
623      * @throws \core_search\engine_exception Engines may throw this exception for any problem
624      */
625     public function delete_index_for_course(int $oldcourseid) {
626         return false;
627     }
629     /**
630      * Checks that the schema is the latest version. If the version stored in config does not match
631      * the current, this function will attempt to upgrade the schema.
632      *
633      * @return bool|string True if schema is OK, a string if user needs to take action
634      */
635     public function check_latest_schema() {
636         if (empty($this->config->schemaversion)) {
637             $currentversion = 0;
638         } else {
639             $currentversion = $this->config->schemaversion;
640         }
641         if ($currentversion < document::SCHEMA_VERSION) {
642             return $this->update_schema((int)$currentversion, (int)document::SCHEMA_VERSION);
643         } else {
644             return true;
645         }
646     }
648     /**
649      * Usually called by the engine; marks that the schema has been updated.
650      *
651      * @param int $version Records the schema version now applied
652      */
653     public function record_applied_schema_version($version) {
654         set_config('schemaversion', $version, $this->pluginname);
655     }
657     /**
658      * Requests the search engine to upgrade the schema. The engine should update the schema if
659      * possible/necessary, and should ensure that record_applied_schema_version is called as a
660      * result.
661      *
662      * If it is not possible to upgrade the schema at the moment, it can do nothing and return; the
663      * function will be called again next time search is initialised.
664      *
665      * The default implementation just returns, with a DEBUG_DEVELOPER warning.
666      *
667      * @param int $oldversion Old schema version
668      * @param int $newversion New schema version
669      * @return bool|string True if schema is updated successfully, a string if it needs updating manually
670      */
671     protected function update_schema($oldversion, $newversion) {
672         debugging('Unable to update search engine schema: ' . $this->pluginname, DEBUG_DEVELOPER);
673         return get_string('schemanotupdated', 'search');
674     }
676     /**
677      * Checks if this search engine supports groups.
678      *
679      * Note that returning true to this function causes the parameters to execute_query to be
680      * passed differently!
681      *
682      * In order to implement groups and return true to this function, the search engine should:
683      *
684      * 1. Handle the fields ->separategroupscontexts and ->usergroups in the $accessinfo parameter
685      *    to execute_query (ideally, using these to automatically restrict search results).
686      * 2. Support the optional groupids parameter in the $filter parameter for execute_query to
687      *    restrict results to only those where the stored groupid matches the given value.
688      *
689      * @return bool True if this engine supports searching by group id field
690      */
691     public function supports_group_filtering() {
692         return false;
693     }
695     /**
696      * Obtain a list of results orders (and names for them) that are supported by this
697      * search engine in the given context.
698      *
699      * By default, engines sort by relevance only.
700      *
701      * @param \context $context Context that the user requested search from
702      * @return array Array from order name => display text
703      */
704     public function get_supported_orders(\context $context) {
705         return ['relevance' => get_string('order_relevance', 'search')];
706     }
708     /**
709      * Checks if the search engine supports searching by user.
710      *
711      * If it returns true to this function, the search engine should support the 'userids' option
712      * in the $filters value passed to execute_query(), returning only items where the userid in
713      * the search document matches one of those user ids.
714      *
715      * @return bool True if the search engine supports searching by user
716      */
717     public function supports_users() {
718         return false;
719     }
721     /**
722      * Checks if the search engine supports adding documents in a batch.
723      *
724      * If it returns true to this function, the search engine must implement the add_document_batch
725      * function.
726      *
727      * @return bool True if the search engine supports adding documents in a batch
728      */
729     public function supports_add_document_batch(): bool {
730         return false;
731     }
733     /**
734      * Gets the maximum number of documents to send together in batch mode.
735      *
736      * Only relevant if the engine returns true to {@see supports_add_document_batch}.
737      *
738      * Can be overridden by search engine if required.
739      *
740      * @var int Number of documents to send together in batch mode, default 100.
741      */
742     public function get_batch_max_documents(): int {
743         return 100;
744     }
746     /**
747      * Gets the maximum size of document content to be included in a shared batch (if the
748      * document is bigger then it will be sent on its own; batching does not provide a performance
749      * improvement for big documents anyway).
750      *
751      * Only relevant if the engine returns true to {@see supports_add_document_batch}.
752      *
753      * Can be overridden by search engine if required.
754      *
755      * @return int Max size in bytes, default 1MB
756      */
757     public function get_batch_max_content(): int {
758         return 1024 * 1024;
759     }
761     /**
762      * Checks if the search engine has an alternate configuration.
763      *
764      * This is used where the same search engine class supports two different configurations,
765      * which are both shown on the settings screen. The alternate configuration is selected by
766      * passing 'true' parameter to the constructor.
767      *
768      * The feature is used when a different connection is in use for indexing vs. querying
769      * the search engine.
770      *
771      * This function should only return true if the engine supports an alternate configuration
772      * and the user has filled in the settings. (We do not need to test they are valid, that will
773      * happen as normal.)
774      *
775      * @return bool True if an alternate configuration is defined
776      */
777     public function has_alternate_configuration(): bool {
778         return false;
779     }