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