MDL-53508 search: Improve highlighting and move to text fields
[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      * Highlighting fragsize. Slightly larger than output size (500) to allow for ... appending.
50      */
51     const FRAG_SIZE = 510;
53     /**
54      * Marker for the start of a highlight.
55      */
56     const HIGHLIGHT_START = '@@HI_S@@';
58     /**
59      * Marker for the end of a highlight.
60      */
61     const HIGHLIGHT_END = '@@HI_E@@';
63     /**
64      * @var \SolrClient
65      */
66     protected $client = null;
68     /**
69      * @var array Fields that can be highlighted.
70      */
71     protected $highlightfields = array('title', 'content', 'description1', 'description2');
73     /**
74      * Prepares a Solr query, applies filters and executes it returning its results.
75      *
76      * @throws \core_search\engine_exception
77      * @param  stdClass  $filters Containing query and filters.
78      * @param  array     $usercontexts Contexts where the user has access. True if the user can access all contexts.
79      * @return \core_search\document[] Results or false if no results
80      */
81     public function execute_query($filters, $usercontexts) {
82         global $USER;
84         // Let's keep these changes internal.
85         $data = clone $filters;
87         // If there is any problem we trigger the exception as soon as possible.
88         $this->client = $this->get_search_client();
90         $serverstatus = $this->is_server_ready();
91         if ($serverstatus !== true) {
92             throw new \core_search\engine_exception('engineserverstatus', 'search');
93         }
95         $query = new \SolrQuery();
96         $this->set_query($query, $data->q);
97         $this->add_fields($query);
99         // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters
100         // we are really interested in caching contexts filters instead.
101         if (!empty($data->title)) {
102             $query->addFilterQuery('{!field cache=false f=title}' . $data->title);
103         }
104         if (!empty($data->areaid)) {
105             // Even if it is only supposed to contain PARAM_ALPHANUMEXT, better to prevent.
106             $query->addFilterQuery('{!field cache=false f=areaid}' . $data->areaid);
107         }
109         if (!empty($data->timestart) or !empty($data->timeend)) {
110             if (empty($data->timestart)) {
111                 $data->timestart = '*';
112             } else {
113                 $data->timestart = \search_solr\document::format_time_for_engine($data->timestart);
114             }
115             if (empty($data->timeend)) {
116                 $data->timeend = '*';
117             } else {
118                 $data->timeend = \search_solr\document::format_time_for_engine($data->timeend);
119             }
121             // No cache.
122             $query->addFilterQuery('{!cache=false}modified:[' . $data->timestart . ' TO ' . $data->timeend . ']');
123         }
125         // Restrict to users who are supposed to be able to see a particular result.
126         $query->addFilterQuery('owneruserid:(' . \core_search\manager::NO_OWNER_ID . ' OR ' . $USER->id . ')');
128         // And finally restrict it to the context where the user can access, we want this one cached.
129         // If the user can access all contexts $usercontexts value is just true, we don't need to filter
130         // in that case.
131         if ($usercontexts && is_array($usercontexts)) {
132             if (!empty($data->areaid)) {
133                 $query->addFilterQuery('contextid:(' . implode(' OR ', $usercontexts[$data->areaid]) . ')');
134             } else {
135                 // Join all area contexts into a single array and implode.
136                 $allcontexts = array();
137                 foreach ($usercontexts as $areacontexts) {
138                     foreach ($areacontexts as $contextid) {
139                         // Ensure they are unique.
140                         $allcontexts[$contextid] = $contextid;
141                     }
142                 }
143                 $query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')');
144             }
145         }
147         try {
148             return $this->query_response($this->client->query($query));
149         } catch (\SolrClientException $ex) {
150             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
151             $this->queryerror = $ex->getMessage();
152             return array();
153         } catch (\SolrServerException $ex) {
154             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
155             $this->queryerror = $ex->getMessage();
156             return array();
157         }
159     }
161     /**
162      * Prepares a new query by setting the query, start offset and rows to return.
163      * @param SolrQuery $query
164      * @param object $q Containing query and filters.
165      */
166     protected function set_query($query, $q) {
168         // Set hightlighting.
169         $query->setHighlight(true);
170         foreach ($this->highlightfields as $field) {
171             $query->addHighlightField($field);
172         }
173         $query->setHighlightFragsize(static::FRAG_SIZE);
174         $query->setHighlightSimplePre(self::HIGHLIGHT_START);
175         $query->setHighlightSimplePost(self::HIGHLIGHT_END);
176         $query->setHighlightMergeContiguous(true);
178         $query->setQuery($q);
180         // A reasonable max.
181         $query->setRows(\core_search\manager::MAX_RESULTS);
182     }
184     /**
185      * Sets fields to be returned in the result.
186      *
187      * @param SolrQuery $query object.
188      */
189     public function add_fields($query) {
190         $documentclass = $this->get_document_classname();
191         $fields = array_keys($documentclass::get_default_fields_definition());
192         foreach ($fields as $field) {
193             $query->addField($field);
194         }
195     }
197     /**
198      * Finds the key common to both highlighing and docs array returned from response.
199      * @param object $response containing results.
200      */
201     public function add_highlight_content($response) {
202         $highlightedobject = $response->highlighting;
203         foreach ($response->response->docs as $doc) {
204             $x = $doc->id;
205             $highlighteddoc = $highlightedobject->$x;
206             $this->merge_highlight_field_values($doc, $highlighteddoc);
207         }
208     }
210     /**
211      * Adds the highlighting array values to docs array values.
212      *
213      * @throws \core_search\engine_exception
214      * @param object $doc containing the results.
215      * @param object $highlighteddoc containing the highlighted results values.
216      */
217     public function merge_highlight_field_values($doc, $highlighteddoc) {
219         foreach ($this->highlightfields as $field) {
220             if (!empty($doc->$field)) {
222                 // Check that the returned value is not an array. No way we can make this work with multivalued solr fields.
223                 if (is_array($doc->{$field})) {
224                     throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $field);
225                 }
227                 if (!empty($highlighteddoc->$field)) {
228                     // Replace by the highlighted result.
229                     $doc->$field = reset($highlighteddoc->$field);
230                 }
231             }
232         }
233     }
235     /**
236      * Filters the response on Moodle side.
237      *
238      * @param object $queryresponse containing the response return from solr server.
239      * @return array $results containing final results to be displayed.
240      */
241     public function query_response($queryresponse) {
242         global $USER;
244         $userid = $USER->id;
245         $noownerid = \core_search\manager::NO_OWNER_ID;
247         $response = $queryresponse->getResponse();
248         $numgranted = 0;
250         if (!$docs = $response->response->docs) {
251             return array();
252         }
254         if (!empty($response->response->numFound)) {
255             $this->add_highlight_content($response);
257             // Iterate through the results checking its availability and whether they are available for the user or not.
258             foreach ($docs as $key => $docdata) {
259                 if ($docdata['owneruserid'] != $noownerid && $docdata['owneruserid'] != $userid) {
260                     // If owneruserid is set, no other user should be able to access this record.
261                     unset($docs[$key]);
262                     continue;
263                 }
265                 if (!$searcharea = $this->get_search_area($docdata->areaid)) {
266                     unset($docs[$key]);
267                     continue;
268                 }
270                 $docdata = $this->standarize_solr_obj($docdata);
272                 $access = $searcharea->check_access($docdata['itemid']);
273                 switch ($access) {
274                     case \core_search\manager::ACCESS_DELETED:
275                         $this->delete_by_id($docdata['id']);
276                         unset($docs[$key]);
277                         break;
278                     case \core_search\manager::ACCESS_DENIED:
279                         unset($docs[$key]);
280                         break;
281                     case \core_search\manager::ACCESS_GRANTED:
282                         $numgranted++;
284                         // Add the doc.
285                         $docs[$key] = $this->to_document($searcharea, $docdata);
286                         break;
287                 }
289                 // This should never happen.
290                 if ($numgranted >= \core_search\manager::MAX_RESULTS) {
291                     $docs = array_slice($docs, 0, \core_search\manager::MAX_RESULTS, true);
292                     break;
293                 }
294             }
295         }
297         return $docs;
298     }
300     /**
301      * Returns a standard php array from a \SolrObject instance.
302      *
303      * @param \SolrObject $obj
304      * @return array The returned document as an array.
305      */
306     public function standarize_solr_obj(\SolrObject $obj) {
307         $properties = $obj->getPropertyNames();
309         $docdata = array();
310         foreach($properties as $name) {
311             // http://php.net/manual/en/solrobject.getpropertynames.php#98018.
312             $name = trim($name);
313             $docdata[$name] = $obj->offsetGet($name);
314         }
315         return $docdata;
316     }
318     /**
319      * Adds a document to the search engine.
320      *
321      * This does not commit to the search engine.
322      *
323      * @param array $doc
324      * @return void
325      */
326     public function add_document($doc) {
328         $solrdoc = new \SolrInputDocument();
329         foreach ($doc as $field => $value) {
330             $solrdoc->addField($field, $value);
331         }
333         try {
334             $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN);
335         } catch (\SolrClientException $e) {
336             debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER);
337         } catch (\SolrServerException $e) {
338             // We only use the first line of the message, as it's a fully java stacktrace behind it.
339             $msg = strtok($e->getMessage(), "\n");
340             debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER);
341         }
342     }
344     /**
345      * Commits all pending changes.
346      *
347      * @return void
348      */
349     protected function commit() {
350         $this->get_search_client()->commit();
351     }
353     /**
354      * Do any area cleanup needed, and do anything to confirm contents.
355      *
356      * Return false to prevent the search area completed time and stats from being updated.
357      *
358      * @param \core_search\area\base $searcharea The search area that was complete
359      * @param int $numdocs The number of documents that were added to the index
360      * @param bool $fullindex True if a full index is being performed
361      * @return bool True means that data is considered indexed
362      */
363     public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) {
364         $this->commit();
366         return true;
367     }
369     /**
370      * Defragments the index.
371      *
372      * @return void
373      */
374     public function optimize() {
375         $this->get_search_client()->optimize(1, true, false);
376     }
378     /**
379      * Deletes the specified document.
380      *
381      * @param string $id The document id to delete
382      * @return void
383      */
384     public function delete_by_id($id) {
385         $this->get_search_client()->deleteById($id);
386         $this->commit();
387     }
389     /**
390      * Delete all area's documents.
391      *
392      * @param string $areaid
393      * @return void
394      */
395     public function delete($areaid = null) {
396         if ($areaid) {
397             $this->get_search_client()->deleteByQuery('areaid:' . $areaid);
398         } else {
399             $this->get_search_client()->deleteByQuery('*:*');
400         }
401         $this->commit();
402     }
404     /**
405      * Pings the Solr server using search_solr config
406      *
407      * @return true|string Returns true if all good or an error string.
408      */
409     public function is_server_ready() {
411         if (empty($this->config->server_hostname) || empty($this->config->indexname)) {
412             return 'No solr configuration found';
413         }
415         if (!$this->client = $this->get_search_client(false)) {
416             return get_string('engineserverstatus', 'search');
417         }
419         try {
420             @$this->client->ping();
421         } catch (\SolrClientException $ex) {
422             return 'Solr client error: ' . $ex->getMessage();
423         } catch (\SolrServerException $ex) {
424             return 'Solr server error: ' . $ex->getMessage();
425         }
427         // Check that setup schema has already run.
428         try {
429             $schema = new \search_solr\schema();
430             $schema->validate_setup();
431         } catch (\moodle_exception $e) {
432             return $e->getMessage();
433         }
435         return true;
436     }
438     /**
439      * Checks if the PHP Solr extension is available.
440      *
441      * @return bool
442      */
443     public function is_installed() {
444         return function_exists('solr_get_version');
445     }
447     /**
448      * Returns the solr client instance.
449      *
450      * @throws \core_search\engine_exception
451      * @param bool $triggerexception
452      * @return \SolrClient
453      */
454     protected function get_search_client($triggerexception = true) {
456         // Type comparison as it is set to false if not available.
457         if ($this->client !== null) {
458             return $this->client;
459         }
461         $options = array(
462             'hostname' => $this->config->server_hostname,
463             'path'     => '/solr/' . $this->config->indexname,
464             'login'    => !empty($this->config->server_username) ? $this->config->server_username : '',
465             'password' => !empty($this->config->server_password) ? $this->config->server_password : '',
466             'port'     => !empty($this->config->server_port) ? $this->config->server_port : '',
467             'issecure' => !empty($this->config->secure) ? $this->config->secure : '',
468             'ssl_cert' => !empty($this->config->ssl_cert) ? $this->config->ssl_cert : '',
469             'ssl_cert_only' => !empty($this->config->ssl_cert_only) ? $this->config->ssl_cert_only : '',
470             'ssl_key' => !empty($this->config->ssl_key) ? $this->config->ssl_key : '',
471             'ssl_password' => !empty($this->config->ssl_keypassword) ? $this->config->ssl_keypassword : '',
472             'ssl_cainfo' => !empty($this->config->ssl_cainfo) ? $this->config->ssl_cainfo : '',
473             'ssl_capath' => !empty($this->config->ssl_capath) ? $this->config->ssl_capath : '',
474             'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'
475         );
477         $this->client = new \SolrClient($options);
479         if ($this->client === false && $triggerexception) {
480             throw new \core_search\engine_exception('engineserverstatus', 'search');
481         }
483         return $this->client;
484     }