MDL-65806 Search: Solr does not find words in italic
[moodle.git] / search / engine / solr / 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  * Solr engine.
19  *
20  * @package    search_solr
21  * @copyright  2015 Daniel Neis Araujo
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace search_solr;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Solr engine.
31  *
32  * @package    search_solr
33  * @copyright  2015 Daniel Neis Araujo
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class engine extends \core_search\engine {
38     /**
39      * @var string The date format used by solr.
40      */
41     const DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
43     /**
44      * @var int Commit documents interval (number of miliseconds).
45      */
46     const AUTOCOMMIT_WITHIN = 15000;
48     /**
49      * The maximum number of results to fetch at a time.
50      */
51     const QUERY_SIZE = 120;
53     /**
54      * Highlighting fragsize. Slightly larger than output size (500) to allow for ... appending.
55      */
56     const FRAG_SIZE = 510;
58     /**
59      * Marker for the start of a highlight.
60      */
61     const HIGHLIGHT_START = '@@HI_S@@';
63     /**
64      * Marker for the end of a highlight.
65      */
66     const HIGHLIGHT_END = '@@HI_E@@';
68     /** @var float Boost value for matching course in location-ordered searches */
69     const COURSE_BOOST = 1;
71     /** @var float Boost value for matching context (in addition to course boost) */
72     const CONTEXT_BOOST = 0.5;
74     /**
75      * @var \SolrClient
76      */
77     protected $client = null;
79     /**
80      * @var bool True if we should reuse SolrClients, false if not.
81      */
82     protected $cacheclient = true;
84     /**
85      * @var \curl Direct curl object.
86      */
87     protected $curl = null;
89     /**
90      * @var array Fields that can be highlighted.
91      */
92     protected $highlightfields = array('title', 'content', 'description1', 'description2');
94     /**
95      * @var int Number of total docs reported by Sorl for the last query.
96      */
97     protected $totalenginedocs = 0;
99     /**
100      * @var int Number of docs we have processed for the last query.
101      */
102     protected $processeddocs = 0;
104     /**
105      * @var int Number of docs that have been skipped while processing the last query.
106      */
107     protected $skippeddocs = 0;
109     /**
110      * Solr server major version.
111      *
112      * @var int
113      */
114     protected $solrmajorversion = null;
116     /**
117      * Initialises the search engine configuration.
118      *
119      * @return void
120      */
121     public function __construct() {
122         parent::__construct();
124         $curlversion = curl_version();
125         if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) {
126             // There is a flaw with curl 7.35.0 that causes problems with client reuse.
127             $this->cacheclient = false;
128         }
129     }
131     /**
132      * Prepares a Solr query, applies filters and executes it returning its results.
133      *
134      * @throws \core_search\engine_exception
135      * @param  \stdClass $filters Containing query and filters.
136      * @param  \stdClass $accessinfo Information about areas user can access.
137      * @param  int       $limit The maximum number of results to return.
138      * @return \core_search\document[] Results or false if no results
139      */
140     public function execute_query($filters, $accessinfo, $limit = 0) {
141         global $USER;
143         if (empty($limit)) {
144             $limit = \core_search\manager::MAX_RESULTS;
145         }
147         // If there is any problem we trigger the exception as soon as possible.
148         $client = $this->get_search_client();
150         // Create the query object.
151         $query = $this->create_user_query($filters, $accessinfo);
153         // If the query cannot have results, return none.
154         if (!$query) {
155             return [];
156         }
158         // We expect good match rates, so for our first get, we will get a small number of records.
159         // This significantly speeds solr response time for first few pages.
160         $query->setRows(min($limit * 3, static::QUERY_SIZE));
161         $response = $this->get_query_response($query);
163         // Get count data out of the response, and reset our counters.
164         list($included, $found) = $this->get_response_counts($response);
165         $this->totalenginedocs = $found;
166         $this->processeddocs = 0;
167         $this->skippeddocs = 0;
168         if ($included == 0 || $this->totalenginedocs == 0) {
169             // No results.
170             return array();
171         }
173         // Get valid documents out of the response.
174         $results = $this->process_response($response, $limit);
176         // We have processed all the docs in the response at this point.
177         $this->processeddocs += $included;
179         // If we haven't reached the limit, and there are more docs left in Solr, lets keep trying.
180         while (count($results) < $limit && ($this->totalenginedocs - $this->processeddocs) > 0) {
181             // Offset the start of the query, and since we are making another call, get more per call.
182             $query->setStart($this->processeddocs);
183             $query->setRows(static::QUERY_SIZE);
185             $response = $this->get_query_response($query);
186             list($included, $found) = $this->get_response_counts($response);
187             if ($included == 0 || $found == 0) {
188                 // No new results were found. Found being empty would be weird, so we will just return.
189                 return $results;
190             }
191             $this->totalenginedocs = $found;
193             // Get the new response docs, limiting to remaining we need, then add it to the end of the results array.
194             $newdocs = $this->process_response($response, $limit - count($results));
195             $results = array_merge($results, $newdocs);
197             // Add to our processed docs count.
198             $this->processeddocs += $included;
199         }
201         return $results;
202     }
204     /**
205      * Takes a query and returns the response in SolrObject format.
206      *
207      * @param  SolrQuery  $query Solr query object.
208      * @return SolrObject|false Response document or false on error.
209      */
210     protected function get_query_response($query) {
211         try {
212             return $this->get_search_client()->query($query)->getResponse();
213         } catch (\SolrClientException $ex) {
214             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
215             $this->queryerror = $ex->getMessage();
216             return false;
217         } catch (\SolrServerException $ex) {
218             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
219             $this->queryerror = $ex->getMessage();
220             return false;
221         }
222     }
224     /**
225      * Returns the total number of documents available for the most recently call to execute_query.
226      *
227      * @return int
228      */
229     public function get_query_total_count() {
230         // Return the total engine count minus the docs we have determined are bad.
231         return $this->totalenginedocs - $this->skippeddocs;
232     }
234     /**
235      * Returns count information for a provided response. Will return 0, 0 for invalid or empty responses.
236      *
237      * @param SolrDocument $response The response document from Solr.
238      * @return array A two part array. First how many response docs are in the response.
239      *               Second, how many results are vailable in the engine.
240      */
241     protected function get_response_counts($response) {
242         $found = 0;
243         $included = 0;
245         if (isset($response->grouped->solr_filegroupingid->ngroups)) {
246             // Get the number of results for file grouped queries.
247             $found = $response->grouped->solr_filegroupingid->ngroups;
248             $included = count($response->grouped->solr_filegroupingid->groups);
249         } else if (isset($response->response->numFound)) {
250             // Get the number of results for standard queries.
251             $found = $response->response->numFound;
252             if ($found > 0 && is_array($response->response->docs)) {
253                 $included = count($response->response->docs);
254             }
255         }
257         return array($included, $found);
258     }
260     /**
261      * Prepares a new query object with needed limits, filters, etc.
262      *
263      * @param \stdClass $filters Containing query and filters.
264      * @param \stdClass $accessinfo Information about contexts the user can access
265      * @return \SolrDisMaxQuery|null Query object or null if they can't get any results
266      */
267     protected function create_user_query($filters, $accessinfo) {
268         global $USER;
270         // Let's keep these changes internal.
271         $data = clone $filters;
273         $query = new \SolrDisMaxQuery();
275         $this->set_query($query, self::replace_underlines($data->q));
276         $this->add_fields($query);
278         // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters
279         // we are really interested in caching contexts filters instead.
280         if (!empty($data->title)) {
281             $query->addFilterQuery('{!field cache=false f=title}' . $data->title);
282         }
283         if (!empty($data->areaids)) {
284             // If areaids are specified, we want to get any that match.
285             $query->addFilterQuery('{!cache=false}areaid:(' . implode(' OR ', $data->areaids) . ')');
286         }
287         if (!empty($data->courseids)) {
288             $query->addFilterQuery('{!cache=false}courseid:(' . implode(' OR ', $data->courseids) . ')');
289         }
290         if (!empty($data->groupids)) {
291             $query->addFilterQuery('{!cache=false}groupid:(' . implode(' OR ', $data->groupids) . ')');
292         }
293         if (!empty($data->userids)) {
294             $query->addFilterQuery('{!cache=false}userid:(' . implode(' OR ', $data->userids) . ')');
295         }
297         if (!empty($data->timestart) or !empty($data->timeend)) {
298             if (empty($data->timestart)) {
299                 $data->timestart = '*';
300             } else {
301                 $data->timestart = \search_solr\document::format_time_for_engine($data->timestart);
302             }
303             if (empty($data->timeend)) {
304                 $data->timeend = '*';
305             } else {
306                 $data->timeend = \search_solr\document::format_time_for_engine($data->timeend);
307             }
309             // No cache.
310             $query->addFilterQuery('{!cache=false}modified:[' . $data->timestart . ' TO ' . $data->timeend . ']');
311         }
313         // Restrict to users who are supposed to be able to see a particular result.
314         $query->addFilterQuery('owneruserid:(' . \core_search\manager::NO_OWNER_ID . ' OR ' . $USER->id . ')');
316         // And finally restrict it to the context where the user can access, we want this one cached.
317         // If the user can access all contexts $usercontexts value is just true, we don't need to filter
318         // in that case.
319         if (!$accessinfo->everything && is_array($accessinfo->usercontexts)) {
320             // Join all area contexts into a single array and implode.
321             $allcontexts = array();
322             foreach ($accessinfo->usercontexts as $areaid => $areacontexts) {
323                 if (!empty($data->areaids) && !in_array($areaid, $data->areaids)) {
324                     // Skip unused areas.
325                     continue;
326                 }
327                 foreach ($areacontexts as $contextid) {
328                     // Ensure they are unique.
329                     $allcontexts[$contextid] = $contextid;
330                 }
331             }
332             if (empty($allcontexts)) {
333                 // This means there are no valid contexts for them, so they get no results.
334                 return null;
335             }
336             $query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')');
337         }
339         if (!$accessinfo->everything && $accessinfo->separategroupscontexts) {
340             // Add another restriction to handle group ids. If there are any contexts using separate
341             // groups, then results in that context will not show unless you belong to the group.
342             // (Note: Access all groups is taken care of earlier, when computing these arrays.)
344             // This special exceptions list allows for particularly pig-headed developers to create
345             // multiple search areas within the same module, where one of them uses separate
346             // groups and the other uses visible groups. It is a little inefficient, but this should
347             // be rare.
348             $exceptions = '';
349             if ($accessinfo->visiblegroupscontextsareas) {
350                 foreach ($accessinfo->visiblegroupscontextsareas as $contextid => $areaids) {
351                     $exceptions .= ' OR (contextid:' . $contextid . ' AND areaid:(' .
352                             implode(' OR ', $areaids) . '))';
353                 }
354             }
356             if ($accessinfo->usergroups) {
357                 // Either the document has no groupid, or the groupid is one that the user
358                 // belongs to, or the context is not one of the separate groups contexts.
359                 $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' .
360                         'groupid:(' . implode(' OR ', $accessinfo->usergroups) . ') OR ' .
361                         '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' .
362                         $exceptions);
363             } else {
364                 // Either the document has no groupid, or the context is not a restricted one.
365                 $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' .
366                         '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' .
367                         $exceptions);
368             }
369         }
371         if ($this->file_indexing_enabled()) {
372             // Now group records by solr_filegroupingid. Limit to 3 results per group.
373             $query->setGroup(true);
374             $query->setGroupLimit(3);
375             $query->setGroupNGroups(true);
376             $query->addGroupField('solr_filegroupingid');
377         } else {
378             // Make sure we only get text files, in case the index has pre-existing files.
379             $query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT);
380         }
382         // If ordering by location, add in boost for the relevant course or context ids.
383         if (!empty($filters->order) && $filters->order === 'location') {
384             $coursecontext = $filters->context->get_course_context();
385             $query->addBoostQuery('courseid', $coursecontext->instanceid, self::COURSE_BOOST);
386             if ($filters->context->contextlevel !== CONTEXT_COURSE) {
387                 // If it's a block or activity, also add a boost for the specific context id.
388                 $query->addBoostQuery('contextid', $filters->context->id, self::CONTEXT_BOOST);
389             }
390         }
392         return $query;
393     }
395     /**
396      * Prepares a new query by setting the query, start offset and rows to return.
397      *
398      * @param SolrQuery $query
399      * @param object    $q Containing query and filters.
400      */
401     protected function set_query($query, $q) {
402         // Set hightlighting.
403         $query->setHighlight(true);
404         foreach ($this->highlightfields as $field) {
405             $query->addHighlightField($field);
406         }
407         $query->setHighlightFragsize(static::FRAG_SIZE);
408         $query->setHighlightSimplePre(self::HIGHLIGHT_START);
409         $query->setHighlightSimplePost(self::HIGHLIGHT_END);
410         $query->setHighlightMergeContiguous(true);
412         $query->setQuery($q);
414         // A reasonable max.
415         $query->setRows(static::QUERY_SIZE);
416     }
418     /**
419      * Sets fields to be returned in the result.
420      *
421      * @param SolrDisMaxQuery|SolrQuery $query object.
422      */
423     public function add_fields($query) {
424         $documentclass = $this->get_document_classname();
425         $fields = $documentclass::get_default_fields_definition();
427         $dismax = false;
428         if ($query instanceof \SolrDisMaxQuery) {
429             $dismax = true;
430         }
432         foreach ($fields as $key => $field) {
433             $query->addField($key);
434             if ($dismax && !empty($field['mainquery'])) {
435                 // Add fields the main query should be run against.
436                 $query->addQueryField($key);
437             }
438         }
439     }
441     /**
442      * Finds the key common to both highlighing and docs array returned from response.
443      * @param object $response containing results.
444      */
445     public function add_highlight_content($response) {
446         if (!isset($response->highlighting)) {
447             // There is no highlighting to add.
448             return;
449         }
451         $highlightedobject = $response->highlighting;
452         foreach ($response->response->docs as $doc) {
453             $x = $doc->id;
454             $highlighteddoc = $highlightedobject->$x;
455             $this->merge_highlight_field_values($doc, $highlighteddoc);
456         }
457     }
459     /**
460      * Adds the highlighting array values to docs array values.
461      *
462      * @throws \core_search\engine_exception
463      * @param object $doc containing the results.
464      * @param object $highlighteddoc containing the highlighted results values.
465      */
466     public function merge_highlight_field_values($doc, $highlighteddoc) {
468         foreach ($this->highlightfields as $field) {
469             if (!empty($doc->$field)) {
471                 // Check that the returned value is not an array. No way we can make this work with multivalued solr fields.
472                 if (is_array($doc->{$field})) {
473                     throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $field);
474                 }
476                 if (!empty($highlighteddoc->$field)) {
477                     // Replace by the highlighted result.
478                     $doc->$field = reset($highlighteddoc->$field);
479                 }
480             }
481         }
482     }
484     /**
485      * Filters the response on Moodle side.
486      *
487      * @param SolrObject $response Solr object containing the response return from solr server.
488      * @param int        $limit The maximum number of results to return. 0 for all.
489      * @param bool       $skipaccesscheck Don't use check_access() on results. Only to be used when results have known access.
490      * @return array $results containing final results to be displayed.
491      */
492     protected function process_response($response, $limit = 0, $skipaccesscheck = false) {
493         global $USER;
495         if (empty($response)) {
496             return array();
497         }
499         if (isset($response->grouped)) {
500             return $this->grouped_files_process_response($response, $limit);
501         }
503         $userid = $USER->id;
504         $noownerid = \core_search\manager::NO_OWNER_ID;
506         $numgranted = 0;
508         if (!$docs = $response->response->docs) {
509             return array();
510         }
512         $out = array();
513         if (!empty($response->response->numFound)) {
514             $this->add_highlight_content($response);
516             // Iterate through the results checking its availability and whether they are available for the user or not.
517             foreach ($docs as $key => $docdata) {
518                 if ($docdata['owneruserid'] != $noownerid && $docdata['owneruserid'] != $userid) {
519                     // If owneruserid is set, no other user should be able to access this record.
520                     continue;
521                 }
523                 if (!$searcharea = $this->get_search_area($docdata->areaid)) {
524                     continue;
525                 }
527                 $docdata = $this->standarize_solr_obj($docdata);
529                 if ($skipaccesscheck) {
530                     $access = \core_search\manager::ACCESS_GRANTED;
531                 } else {
532                     $access = $searcharea->check_access($docdata['itemid']);
533                 }
534                 switch ($access) {
535                     case \core_search\manager::ACCESS_DELETED:
536                         $this->delete_by_id($docdata['id']);
537                         // Remove one from our processed and total counters, since we promptly deleted.
538                         $this->processeddocs--;
539                         $this->totalenginedocs--;
540                         break;
541                     case \core_search\manager::ACCESS_DENIED:
542                         $this->skippeddocs++;
543                         break;
544                     case \core_search\manager::ACCESS_GRANTED:
545                         $numgranted++;
547                         // Add the doc.
548                         $out[] = $this->to_document($searcharea, $docdata);
549                         break;
550                 }
552                 // Stop when we hit our limit.
553                 if (!empty($limit) && count($out) >= $limit) {
554                     break;
555                 }
556             }
557         }
559         return $out;
560     }
562     /**
563      * Processes grouped file results into documents, with attached matching files.
564      *
565      * @param SolrObject $response The response returned from solr server
566      * @param int        $limit The maximum number of results to return. 0 for all.
567      * @return array Final results to be displayed.
568      */
569     protected function grouped_files_process_response($response, $limit = 0) {
570         // If we can't find the grouping, or there are no matches in the grouping, return empty.
571         if (!isset($response->grouped->solr_filegroupingid) || empty($response->grouped->solr_filegroupingid->matches)) {
572             return array();
573         }
575         $numgranted = 0;
576         $orderedids = array();
577         $completedocs = array();
578         $incompletedocs = array();
580         $highlightingobj = $response->highlighting;
582         // Each group represents a "master document".
583         $groups = $response->grouped->solr_filegroupingid->groups;
584         foreach ($groups as $group) {
585             $groupid = $group->groupValue;
586             $groupdocs = $group->doclist->docs;
587             $firstdoc = reset($groupdocs);
589             if (!$searcharea = $this->get_search_area($firstdoc->areaid)) {
590                 // Well, this is a problem.
591                 continue;
592             }
594             // Check for access.
595             $access = $searcharea->check_access($firstdoc->itemid);
596             switch ($access) {
597                 case \core_search\manager::ACCESS_DELETED:
598                     // If deleted from Moodle, delete from index and then continue.
599                     $this->delete_by_id($firstdoc->id);
600                     // Remove one from our processed and total counters, since we promptly deleted.
601                     $this->processeddocs--;
602                     $this->totalenginedocs--;
603                     continue 2;
604                     break;
605                 case \core_search\manager::ACCESS_DENIED:
606                     // This means we should just skip for the current user.
607                     $this->skippeddocs++;
608                     continue 2;
609                     break;
610             }
611             $numgranted++;
613             $maindoc = false;
614             $fileids = array();
615             // Seperate the main document and any files returned.
616             foreach ($groupdocs as $groupdoc) {
617                 if ($groupdoc->id == $groupid) {
618                     $maindoc = $groupdoc;
619                 } else if (isset($groupdoc->solr_fileid)) {
620                     $fileids[] = $groupdoc->solr_fileid;
621                 }
622             }
624             // Store the id of this group, in order, for later merging.
625             $orderedids[] = $groupid;
627             if (!$maindoc) {
628                 // We don't have the main doc, store what we know for later building.
629                 $incompletedocs[$groupid] = $fileids;
630             } else {
631                 if (isset($highlightingobj->$groupid)) {
632                     // Merge the highlighting for this doc.
633                     $this->merge_highlight_field_values($maindoc, $highlightingobj->$groupid);
634                 }
635                 $docdata = $this->standarize_solr_obj($maindoc);
636                 $doc = $this->to_document($searcharea, $docdata);
637                 // Now we need to attach the result files to the doc.
638                 foreach ($fileids as $fileid) {
639                     $doc->add_stored_file($fileid);
640                 }
641                 $completedocs[$groupid] = $doc;
642             }
644             if (!empty($limit) && $numgranted >= $limit) {
645                 // We have hit the max results, we will just ignore the rest.
646                 break;
647             }
648         }
650         $incompletedocs = $this->get_missing_docs($incompletedocs);
652         $out = array();
653         // Now merge the complete and incomplete documents, in results order.
654         foreach ($orderedids as $docid) {
655             if (isset($completedocs[$docid])) {
656                 $out[] = $completedocs[$docid];
657             } else if (isset($incompletedocs[$docid])) {
658                 $out[] = $incompletedocs[$docid];
659             }
660         }
662         return $out;
663     }
665     /**
666      * Retreive any missing main documents and attach provided files.
667      *
668      * The missingdocs array should be an array, indexed by document id, of main documents we need to retrieve. The value
669      * associated to the key should be an array of stored_files or stored file ids to attach to the result document.
670      *
671      * Return array also indexed by document id.
672      *
673      * @param array() $missingdocs An array, indexed by document id, with arrays of files/ids to attach.
674      * @return document[]
675      */
676     protected function get_missing_docs($missingdocs) {
677         if (empty($missingdocs)) {
678             return array();
679         }
681         $docids = array_keys($missingdocs);
683         // Build a custom query that will get all the missing documents.
684         $query = new \SolrQuery();
685         $this->set_query($query, '*');
686         $this->add_fields($query);
687         $query->setRows(count($docids));
688         $query->addFilterQuery('{!cache=false}id:(' . implode(' OR ', $docids) . ')');
690         $response = $this->get_query_response($query);
691         // We know the missing docs have already been checked for access, so don't recheck.
692         $results = $this->process_response($response, 0, true);
694         $out = array();
695         foreach ($results as $result) {
696             $resultid = $result->get('id');
697             if (!isset($missingdocs[$resultid])) {
698                 // We got a result we didn't expect. Skip it.
699                 continue;
700             }
701             // Attach the files.
702             foreach ($missingdocs[$resultid] as $filedoc) {
703                 $result->add_stored_file($filedoc);
704             }
705             $out[$resultid] = $result;
706         }
708         return $out;
709     }
711     /**
712      * Returns a standard php array from a \SolrObject instance.
713      *
714      * @param \SolrObject $obj
715      * @return array The returned document as an array.
716      */
717     public function standarize_solr_obj(\SolrObject $obj) {
718         $properties = $obj->getPropertyNames();
720         $docdata = array();
721         foreach($properties as $name) {
722             // http://php.net/manual/en/solrobject.getpropertynames.php#98018.
723             $name = trim($name);
724             $docdata[$name] = $obj->offsetGet($name);
725         }
726         return $docdata;
727     }
729     /**
730      * Adds a document to the search engine.
731      *
732      * This does not commit to the search engine.
733      *
734      * @param document $document
735      * @param bool     $fileindexing True if file indexing is to be used
736      * @return bool
737      */
738     public function add_document($document, $fileindexing = false) {
739         $docdata = $document->export_for_engine();
741         if (!$this->add_solr_document($docdata)) {
742             return false;
743         }
745         if ($fileindexing) {
746             // This will take care of updating all attached files in the index.
747             $this->process_document_files($document);
748         }
750         return true;
751     }
753     /**
754      * Replaces underlines at edges of words in the content with spaces.
755      *
756      * For example '_frogs_' will become 'frogs', '_frogs and toads_' will become 'frogs and toads',
757      * and 'frogs_and_toads' will be left as 'frogs_and_toads'.
758      *
759      * The reason for this is that for italic content_to_text puts _italic_ underlines at the start
760      * and end of the italicised phrase (not between words). Solr treats underlines as part of the
761      * word, which means that if you search for a word in italic then you can't find it.
762      *
763      * @param string $str String to replace
764      * @return string Replaced string
765      */
766     protected static function replace_underlines(string $str): string {
767         return preg_replace('~\b_|_\b~', '', $str);
768     }
770     /**
771      * Adds a text document to the search engine.
772      *
773      * @param array $doc
774      * @return bool
775      */
776     protected function add_solr_document($doc) {
777         $solrdoc = new \SolrInputDocument();
779         // Replace underlines in the content with spaces. The reason for this is that for italic
780         // text, content_to_text puts _italic_ underlines. Solr treats underlines as part of the
781         // word, which means that if you search for a word in italic then you can't find it.
782         if (array_key_exists('content', $doc)) {
783             $doc['content'] = self::replace_underlines($doc['content']);
784         }
786         foreach ($doc as $field => $value) {
787             $solrdoc->addField($field, $value);
788         }
790         try {
791             $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN);
792             return true;
793         } catch (\SolrClientException $e) {
794             debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER);
795         } catch (\SolrServerException $e) {
796             // We only use the first line of the message, as it's a fully java stacktrace behind it.
797             $msg = strtok($e->getMessage(), "\n");
798             debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER);
799         }
801         return false;
802     }
804     /**
805      * Index files attached to the docuemnt, ensuring the index matches the current document files.
806      *
807      * For documents that aren't known to be new, we check the index for existing files.
808      * - New files we will add.
809      * - Existing and unchanged files we will skip.
810      * - File that are in the index but not on the document will be deleted from the index.
811      * - Files that have changed will be re-indexed.
812      *
813      * @param document $document
814      */
815     protected function process_document_files($document) {
816         if (!$this->file_indexing_enabled()) {
817             return;
818         }
820         // Maximum rows to process at a time.
821         $rows = 500;
823         // Get the attached files.
824         $files = $document->get_files();
826         // If this isn't a new document, we need to check the exiting indexed files.
827         if (!$document->get_is_new()) {
828             // We do this progressively, so we can handle lots of files cleanly.
829             list($numfound, $indexedfiles) = $this->get_indexed_files($document, 0, $rows);
830             $count = 0;
831             $idstodelete = array();
833             do {
834                 // Go through each indexed file. We want to not index any stored and unchanged ones, delete any missing ones.
835                 foreach ($indexedfiles as $indexedfile) {
836                     $fileid = $indexedfile->solr_fileid;
838                     if (isset($files[$fileid])) {
839                         // Check for changes that would mean we need to re-index the file. If so, just leave in $files.
840                         // Filelib does not guarantee time modified is updated, so we will check important values.
841                         if ($indexedfile->modified != $files[$fileid]->get_timemodified()) {
842                             continue;
843                         }
844                         if (strcmp($indexedfile->title, $files[$fileid]->get_filename()) !== 0) {
845                             continue;
846                         }
847                         if ($indexedfile->solr_filecontenthash != $files[$fileid]->get_contenthash()) {
848                             continue;
849                         }
850                         if ($indexedfile->solr_fileindexstatus == document::INDEXED_FILE_FALSE &&
851                                 $this->file_is_indexable($files[$fileid])) {
852                             // This means that the last time we indexed this file, filtering blocked it.
853                             // Current settings say it is indexable, so we will allow it to be indexed.
854                             continue;
855                         }
857                         // If the file is already indexed, we can just remove it from the files array and skip it.
858                         unset($files[$fileid]);
859                     } else {
860                         // This means we have found a file that is no longer attached, so we need to delete from the index.
861                         // We do it later, since this is progressive, and it could reorder results.
862                         $idstodelete[] = $indexedfile->id;
863                     }
864                 }
865                 $count += $rows;
867                 if ($count < $numfound) {
868                     // If we haven't hit the total count yet, fetch the next batch.
869                     list($numfound, $indexedfiles) = $this->get_indexed_files($document, $count, $rows);
870                 }
872             } while ($count < $numfound);
874             // Delete files that are no longer attached.
875             foreach ($idstodelete as $id) {
876                 // We directly delete the item using the client, as the engine delete_by_id won't work on file docs.
877                 $this->get_search_client()->deleteById($id);
878             }
879         }
881         // Now we can actually index all the remaining files.
882         foreach ($files as $file) {
883             $this->add_stored_file($document, $file);
884         }
885     }
887     /**
888      * Get the currently indexed files for a particular document, returns the total count, and a subset of files.
889      *
890      * @param document $document
891      * @param int      $start The row to start the results on. Zero indexed.
892      * @param int      $rows The number of rows to fetch
893      * @return array   A two element array, the first is the total number of availble results, the second is an array
894      *                 of documents for the current request.
895      */
896     protected function get_indexed_files($document, $start = 0, $rows = 500) {
897         // Build a custom query that will get any document files that are in our solr_filegroupingid.
898         $query = new \SolrQuery();
900         // We want to get all file records tied to a document.
901         // For efficiency, we are building our own, stripped down, query.
902         $query->setQuery('*');
903         $query->setRows($rows);
904         $query->setStart($start);
905         // We want a consistent sorting.
906         $query->addSortField('id');
908         // We only want the bare minimum of fields.
909         $query->addField('id');
910         $query->addField('modified');
911         $query->addField('title');
912         $query->addField('solr_fileid');
913         $query->addField('solr_filecontenthash');
914         $query->addField('solr_fileindexstatus');
916         $query->addFilterQuery('{!cache=false}solr_filegroupingid:(' . $document->get('id') . ')');
917         $query->addFilterQuery('type:' . \core_search\manager::TYPE_FILE);
919         $response = $this->get_query_response($query);
920         if (empty($response->response->numFound)) {
921             return array(0, array());
922         }
924         return array($response->response->numFound, $this->convert_file_results($response));
925     }
927     /**
928      * A very lightweight handler for getting information about already indexed files from a Solr response.
929      *
930      * @param SolrObject $responsedoc A Solr response document
931      * @return stdClass[] An array of objects that contain the basic information for file processing.
932      */
933     protected function convert_file_results($responsedoc) {
934         if (!$docs = $responsedoc->response->docs) {
935             return array();
936         }
938         $out = array();
940         foreach ($docs as $doc) {
941             // Copy the bare minimim needed info.
942             $result = new \stdClass();
943             $result->id = $doc->id;
944             $result->modified = document::import_time_from_engine($doc->modified);
945             $result->title = $doc->title;
946             $result->solr_fileid = $doc->solr_fileid;
947             $result->solr_filecontenthash = $doc->solr_filecontenthash;
948             $result->solr_fileindexstatus = $doc->solr_fileindexstatus;
949             $out[] = $result;
950         }
952         return $out;
953     }
955     /**
956      * Adds a file to the search engine.
957      *
958      * Notes about Solr and Tika indexing. We do not send the mime type, only the filename.
959      * Tika has much better content type detection than Moodle, and we will have many more doc failures
960      * if we try to send mime types.
961      *
962      * @param document $document
963      * @param \stored_file $storedfile
964      * @return void
965      */
966     protected function add_stored_file($document, $storedfile) {
967         $filedoc = $document->export_file_for_engine($storedfile);
969         if (!$this->file_is_indexable($storedfile)) {
970             // For files that we don't consider indexable, we will still place a reference in the search engine.
971             $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_FALSE;
972             $this->add_solr_document($filedoc);
973             return;
974         }
976         $curl = $this->get_curl_object();
978         $url = $this->get_connection_url('/update/extract');
980         // Return results as XML.
981         $url->param('wt', 'xml');
983         // This will prevent solr from automatically making fields for every tika output.
984         $url->param('uprefix', 'ignored_');
986         // Control how content is captured. This will keep our file content clean of non-important metadata.
987         $url->param('captureAttr', 'true');
988         // Move the content to a field for indexing.
989         $url->param('fmap.content', 'solr_filecontent');
991         // These are common fields that matches the standard *_point dynamic field and causes an error.
992         $url->param('fmap.media_white_point', 'ignored_mwp');
993         $url->param('fmap.media_black_point', 'ignored_mbp');
995         // Copy each key to the url with literal.
996         // We place in a temp name then copy back to the true field, which prevents errors or Tika overwriting common field names.
997         foreach ($filedoc as $key => $value) {
998             // This will take any fields from tika that match our schema and discard them, so they don't overwrite ours.
999             $url->param('fmap.'.$key, 'ignored_'.$key);
1000             // Place data in a tmp field.
1001             $url->param('literal.mdltmp_'.$key, $value);
1002             // Then move to the final field.
1003             $url->param('fmap.mdltmp_'.$key, $key);
1004         }
1006         // This sets the true filename for Tika.
1007         $url->param('resource.name', $storedfile->get_filename());
1009         // A giant block of code that is really just error checking around the curl request.
1010         try {
1011             // Now actually do the request.
1012             $result = $curl->post($url->out(false), array('myfile' => $storedfile));
1014             $code = $curl->get_errno();
1015             $info = $curl->get_info();
1017             // Now error handling. It is just informational, since we aren't tracking per file/doc results.
1018             if ($code != 0) {
1019                 // This means an internal cURL error occurred error is in result.
1020                 $message = 'Curl error '.$code.' while indexing file with document id '.$filedoc['id'].': '.$result.'.';
1021                 debugging($message, DEBUG_DEVELOPER);
1022             } else if (isset($info['http_code']) && ($info['http_code'] !== 200)) {
1023                 // Unexpected HTTP response code.
1024                 $message = 'Error while indexing file with document id '.$filedoc['id'];
1025                 // Try to get error message out of msg or title if it exists.
1026                 if (preg_match('|<str [^>]*name="msg"[^>]*>(.*?)</str>|i', $result, $matches)) {
1027                     $message .= ': '.$matches[1];
1028                 } else if (preg_match('|<title[^>]*>([^>]*)</title>|i', $result, $matches)) {
1029                     $message .= ': '.$matches[1];
1030                 }
1031                 // This is a common error, happening whenever a file fails to index for any reason, so we will make it quieter.
1032                 if (CLI_SCRIPT && !PHPUNIT_TEST) {
1033                     mtrace($message);
1034                 }
1035             } else {
1036                 // Check for the expected status field.
1037                 if (preg_match('|<int [^>]*name="status"[^>]*>(\d*)</int>|i', $result, $matches)) {
1038                     // Now check for the expected status of 0, if not, error.
1039                     if ((int)$matches[1] !== 0) {
1040                         $message = 'Unexpected Solr status code '.(int)$matches[1];
1041                         $message .= ' while indexing file with document id '.$filedoc['id'].'.';
1042                         debugging($message, DEBUG_DEVELOPER);
1043                     } else {
1044                         // The document was successfully indexed.
1045                         return;
1046                     }
1047                 } else {
1048                     // We received an unprocessable response.
1049                     $message = 'Unexpected Solr response while indexing file with document id '.$filedoc['id'].': ';
1050                     $message .= strtok($result, "\n");
1051                     debugging($message, DEBUG_DEVELOPER);
1052                 }
1053             }
1054         } catch (\Exception $e) {
1055             // There was an error, but we are not tracking per-file success, so we just continue on.
1056             debugging('Unknown exception while indexing file "'.$storedfile->get_filename().'".', DEBUG_DEVELOPER);
1057         }
1059         // If we get here, the document was not indexed due to an error. So we will index just the base info without the file.
1060         $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_ERROR;
1061         $this->add_solr_document($filedoc);
1062     }
1064     /**
1065      * Checks to see if a passed file is indexable.
1066      *
1067      * @param \stored_file $file The file to check
1068      * @return bool True if the file can be indexed
1069      */
1070     protected function file_is_indexable($file) {
1071         if (!empty($this->config->maxindexfilekb) && ($file->get_filesize() > ($this->config->maxindexfilekb * 1024))) {
1072             // The file is too big to index.
1073             return false;
1074         }
1076         $mime = $file->get_mimetype();
1078         if ($mime == 'application/vnd.moodle.backup') {
1079             // We don't index Moodle backup files. There is nothing usefully indexable in them.
1080             return false;
1081         }
1083         return true;
1084     }
1086     /**
1087      * Commits all pending changes.
1088      *
1089      * @return void
1090      */
1091     protected function commit() {
1092         $this->get_search_client()->commit();
1093     }
1095     /**
1096      * Do any area cleanup needed, and do anything to confirm contents.
1097      *
1098      * Return false to prevent the search area completed time and stats from being updated.
1099      *
1100      * @param \core_search\base $searcharea The search area that was complete
1101      * @param int $numdocs The number of documents that were added to the index
1102      * @param bool $fullindex True if a full index is being performed
1103      * @return bool True means that data is considered indexed
1104      */
1105     public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) {
1106         $this->commit();
1108         return true;
1109     }
1111     /**
1112      * Return true if file indexing is supported and enabled. False otherwise.
1113      *
1114      * @return bool
1115      */
1116     public function file_indexing_enabled() {
1117         return (bool)$this->config->fileindexing;
1118     }
1120     /**
1121      * Defragments the index.
1122      *
1123      * @return void
1124      */
1125     public function optimize() {
1126         $this->get_search_client()->optimize(1, true, false);
1127     }
1129     /**
1130      * Deletes the specified document.
1131      *
1132      * @param string $id The document id to delete
1133      * @return void
1134      */
1135     public function delete_by_id($id) {
1136         // We need to make sure we delete the item and all related files, which can be done with solr_filegroupingid.
1137         $this->get_search_client()->deleteByQuery('solr_filegroupingid:' . $id);
1138         $this->commit();
1139     }
1141     /**
1142      * Delete all area's documents.
1143      *
1144      * @param string $areaid
1145      * @return void
1146      */
1147     public function delete($areaid = null) {
1148         if ($areaid) {
1149             $this->get_search_client()->deleteByQuery('areaid:' . $areaid);
1150         } else {
1151             $this->get_search_client()->deleteByQuery('*:*');
1152         }
1153         $this->commit();
1154     }
1156     /**
1157      * Pings the Solr server using search_solr config
1158      *
1159      * @return true|string Returns true if all good or an error string.
1160      */
1161     public function is_server_ready() {
1163         $configured = $this->is_server_configured();
1164         if ($configured !== true) {
1165             return $configured;
1166         }
1168         // As part of the above we have already checked that we can contact the server. For pages
1169         // where performance is important, we skip doing a full schema check as well.
1170         if ($this->should_skip_schema_check()) {
1171             return true;
1172         }
1174         // Update schema if required/possible.
1175         $schemalatest = $this->check_latest_schema();
1176         if ($schemalatest !== true) {
1177             return $schemalatest;
1178         }
1180         // Check that the schema is already set up.
1181         try {
1182             $schema = new \search_solr\schema();
1183             $schema->validate_setup();
1184         } catch (\moodle_exception $e) {
1185             return $e->getMessage();
1186         }
1188         return true;
1189     }
1191     /**
1192      * Is the solr server properly configured?.
1193      *
1194      * @return true|string Returns true if all good or an error string.
1195      */
1196     public function is_server_configured() {
1198         if (empty($this->config->server_hostname) || empty($this->config->indexname)) {
1199             return 'No solr configuration found';
1200         }
1202         if (!$client = $this->get_search_client(false)) {
1203             return get_string('engineserverstatus', 'search');
1204         }
1206         try {
1207             if ($this->get_solr_major_version() < 4) {
1208                 // Minimum solr 4.0.
1209                 return get_string('minimumsolr4', 'search_solr');
1210             }
1211         } catch (\SolrClientException $ex) {
1212             debugging('Solr client error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER);
1213             return get_string('engineserverstatus', 'search');
1214         } catch (\SolrServerException $ex) {
1215             debugging('Solr server error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER);
1216             return get_string('engineserverstatus', 'search');
1217         }
1219         return true;
1220     }
1222     /**
1223      * Returns the solr server major version.
1224      *
1225      * @return int
1226      */
1227     public function get_solr_major_version() {
1228         if ($this->solrmajorversion !== null) {
1229             return $this->solrmajorversion;
1230         }
1232         // We should really ping first the server to see if the specified indexname is valid but
1233         // we want to minimise solr server requests as they are expensive. system() emits a warning
1234         // if it can not connect to the configured index in the configured server.
1235         $systemdata = @$this->get_search_client()->system();
1236         $solrversion = $systemdata->getResponse()->offsetGet('lucene')->offsetGet('solr-spec-version');
1237         $this->solrmajorversion = intval(substr($solrversion, 0, strpos($solrversion, '.')));
1239         return $this->solrmajorversion;
1240     }
1242     /**
1243      * Checks if the PHP Solr extension is available.
1244      *
1245      * @return bool
1246      */
1247     public function is_installed() {
1248         return function_exists('solr_get_version');
1249     }
1251     /**
1252      * Returns the solr client instance.
1253      *
1254      * We don't reuse SolrClient if we are on libcurl 7.35.0, due to a bug in that version of curl.
1255      *
1256      * @throws \core_search\engine_exception
1257      * @param bool $triggerexception
1258      * @return \SolrClient
1259      */
1260     protected function get_search_client($triggerexception = true) {
1261         global $CFG;
1263         // Type comparison as it is set to false if not available.
1264         if ($this->client !== null) {
1265             return $this->client;
1266         }
1268         $options = array(
1269             'hostname' => $this->config->server_hostname,
1270             'path'     => '/solr/' . $this->config->indexname,
1271             'login'    => !empty($this->config->server_username) ? $this->config->server_username : '',
1272             'password' => !empty($this->config->server_password) ? $this->config->server_password : '',
1273             'port'     => !empty($this->config->server_port) ? $this->config->server_port : '',
1274             'secure' => !empty($this->config->secure) ? true : false,
1275             'ssl_cert' => !empty($this->config->ssl_cert) ? $this->config->ssl_cert : '',
1276             'ssl_key' => !empty($this->config->ssl_key) ? $this->config->ssl_key : '',
1277             'ssl_keypassword' => !empty($this->config->ssl_keypassword) ? $this->config->ssl_keypassword : '',
1278             'ssl_cainfo' => !empty($this->config->ssl_cainfo) ? $this->config->ssl_cainfo : '',
1279             'ssl_capath' => !empty($this->config->ssl_capath) ? $this->config->ssl_capath : '',
1280             'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'
1281         );
1283         if ($CFG->proxyhost && !is_proxybypass('http://' . $this->config->server_hostname . '/')) {
1284             $options['proxy_host'] = $CFG->proxyhost;
1285             if (!empty($CFG->proxyport)) {
1286                 $options['proxy_port'] = $CFG->proxyport;
1287             }
1288             if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) {
1289                 $options['proxy_login'] = $CFG->proxyuser;
1290                 $options['proxy_password'] = $CFG->proxypassword;
1291             }
1292         }
1294         if (!class_exists('\SolrClient')) {
1295             throw new \core_search\engine_exception('enginenotinstalled', 'search', '', 'solr');
1296         }
1298         $client = new \SolrClient($options);
1300         if ($client === false && $triggerexception) {
1301             throw new \core_search\engine_exception('engineserverstatus', 'search');
1302         }
1304         if ($this->cacheclient) {
1305             $this->client = $client;
1306         }
1308         return $client;
1309     }
1311     /**
1312      * Returns a curl object for conntecting to solr.
1313      *
1314      * @return \curl
1315      */
1316     public function get_curl_object() {
1317         if (!is_null($this->curl)) {
1318             return $this->curl;
1319         }
1321         // Connection to Solr is allowed to use 'localhost' and other potentially blocked hosts/ports.
1322         $this->curl = new \curl(['ignoresecurity' => true]);
1324         $options = array();
1325         // Build the SSL options. Based on pecl-solr and general testing.
1326         if (!empty($this->config->secure)) {
1327             if (!empty($this->config->ssl_cert)) {
1328                 $options['CURLOPT_SSLCERT'] = $this->config->ssl_cert;
1329                 $options['CURLOPT_SSLCERTTYPE'] = 'PEM';
1330             }
1332             if (!empty($this->config->ssl_key)) {
1333                 $options['CURLOPT_SSLKEY'] = $this->config->ssl_key;
1334                 $options['CURLOPT_SSLKEYTYPE'] = 'PEM';
1335             }
1337             if (!empty($this->config->ssl_keypassword)) {
1338                 $options['CURLOPT_KEYPASSWD'] = $this->config->ssl_keypassword;
1339             }
1341             if (!empty($this->config->ssl_cainfo)) {
1342                 $options['CURLOPT_CAINFO'] = $this->config->ssl_cainfo;
1343             }
1345             if (!empty($this->config->ssl_capath)) {
1346                 $options['CURLOPT_CAPATH'] = $this->config->ssl_capath;
1347             }
1348         }
1350         // Set timeout as for Solr client.
1351         $options['CURLOPT_TIMEOUT'] = !empty($this->config->server_timeout) ? $this->config->server_timeout : '30';
1353         $this->curl->setopt($options);
1355         if (!empty($this->config->server_username) && !empty($this->config->server_password)) {
1356             $authorization = $this->config->server_username . ':' . $this->config->server_password;
1357             $this->curl->setHeader('Authorization: Basic ' . base64_encode($authorization));
1358         }
1360         return $this->curl;
1361     }
1363     /**
1364      * Return a Moodle url object for the server connection.
1365      *
1366      * @param string $path The solr path to append.
1367      * @return \moodle_url
1368      */
1369     public function get_connection_url($path) {
1370         // Must use the proper protocol, or SSL will fail.
1371         $protocol = !empty($this->config->secure) ? 'https' : 'http';
1372         $url = $protocol . '://' . rtrim($this->config->server_hostname, '/');
1373         if (!empty($this->config->server_port)) {
1374             $url .= ':' . $this->config->server_port;
1375         }
1376         $url .= '/solr/' . $this->config->indexname . '/' . ltrim($path, '/');
1378         return new \moodle_url($url);
1379     }
1381     /**
1382      * Solr includes group support in the execute_query function.
1383      *
1384      * @return bool True
1385      */
1386     public function supports_group_filtering() {
1387         return true;
1388     }
1390     protected function update_schema($oldversion, $newversion) {
1391         // Construct schema.
1392         $schema = new schema();
1393         $cansetup = $schema->can_setup_server();
1394         if ($cansetup !== true) {
1395             return $cansetup;
1396         }
1398         switch ($newversion) {
1399             // This version just requires a setup call to add new fields.
1400             case 2017091700:
1401                 $setup = true;
1402                 break;
1404             // If we don't know about the schema version we might not have implemented the
1405             // change correctly, so return.
1406             default:
1407                 return get_string('schemaversionunknown', 'search');
1408         }
1410         if ($setup) {
1411             $schema->setup();
1412         }
1414         return true;
1415     }
1417     /**
1418      * Solr supports sort by location within course contexts or below.
1419      *
1420      * @param \context $context Context that the user requested search from
1421      * @return array Array from order name => display text
1422      */
1423     public function get_supported_orders(\context $context) {
1424         $orders = parent::get_supported_orders($context);
1426         // If not within a course, no other kind of sorting supported.
1427         $coursecontext = $context->get_course_context(false);
1428         if ($coursecontext) {
1429             // Within a course or activity/block, support sort by location.
1430             $orders['location'] = get_string('order_location', 'search',
1431                     $context->get_context_name());
1432         }
1434         return $orders;
1435     }
1437     /**
1438      * Solr supports search by user id.
1439      *
1440      * @return bool True
1441      */
1442     public function supports_users() {
1443         return true;
1444     }