Merge branch 'MDL-53515-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      * 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 \curl Direct curl object.
70      */
71     protected $curl = null;
73     /**
74      * @var array Fields that can be highlighted.
75      */
76     protected $highlightfields = array('title', 'content', 'description1', 'description2');
78     /**
79      * Prepares a Solr query, applies filters and executes it returning its results.
80      *
81      * @throws \core_search\engine_exception
82      * @param  stdClass  $filters Containing query and filters.
83      * @param  array     $usercontexts Contexts where the user has access. True if the user can access all contexts.
84      * @return \core_search\document[] Results or false if no results
85      */
86     public function execute_query($filters, $usercontexts) {
87         global $USER;
89         // Let's keep these changes internal.
90         $data = clone $filters;
92         // If there is any problem we trigger the exception as soon as possible.
93         $this->client = $this->get_search_client();
95         $serverstatus = $this->is_server_ready();
96         if ($serverstatus !== true) {
97             throw new \core_search\engine_exception('engineserverstatus', 'search');
98         }
100         $query = new \SolrQuery();
101         $this->set_query($query, $data->q);
102         $this->add_fields($query);
104         // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters
105         // we are really interested in caching contexts filters instead.
106         if (!empty($data->title)) {
107             $query->addFilterQuery('{!field cache=false f=title}' . $data->title);
108         }
109         if (!empty($data->areaid)) {
110             // Even if it is only supposed to contain PARAM_ALPHANUMEXT, better to prevent.
111             $query->addFilterQuery('{!field cache=false f=areaid}' . $data->areaid);
112         }
114         if (!empty($data->timestart) or !empty($data->timeend)) {
115             if (empty($data->timestart)) {
116                 $data->timestart = '*';
117             } else {
118                 $data->timestart = \search_solr\document::format_time_for_engine($data->timestart);
119             }
120             if (empty($data->timeend)) {
121                 $data->timeend = '*';
122             } else {
123                 $data->timeend = \search_solr\document::format_time_for_engine($data->timeend);
124             }
126             // No cache.
127             $query->addFilterQuery('{!cache=false}modified:[' . $data->timestart . ' TO ' . $data->timeend . ']');
128         }
130         // Restrict to users who are supposed to be able to see a particular result.
131         $query->addFilterQuery('owneruserid:(' . \core_search\manager::NO_OWNER_ID . ' OR ' . $USER->id . ')');
133         // And finally restrict it to the context where the user can access, we want this one cached.
134         // If the user can access all contexts $usercontexts value is just true, we don't need to filter
135         // in that case.
136         if ($usercontexts && is_array($usercontexts)) {
137             if (!empty($data->areaid)) {
138                 $query->addFilterQuery('contextid:(' . implode(' OR ', $usercontexts[$data->areaid]) . ')');
139             } else {
140                 // Join all area contexts into a single array and implode.
141                 $allcontexts = array();
142                 foreach ($usercontexts as $areacontexts) {
143                     foreach ($areacontexts as $contextid) {
144                         // Ensure they are unique.
145                         $allcontexts[$contextid] = $contextid;
146                     }
147                 }
148                 $query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')');
149             }
150         }
152         try {
153             return $this->query_response($this->client->query($query));
154         } catch (\SolrClientException $ex) {
155             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
156             $this->queryerror = $ex->getMessage();
157             return array();
158         } catch (\SolrServerException $ex) {
159             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
160             $this->queryerror = $ex->getMessage();
161             return array();
162         }
164     }
166     /**
167      * Prepares a new query by setting the query, start offset and rows to return.
168      * @param SolrQuery $query
169      * @param object $q Containing query and filters.
170      */
171     protected function set_query($query, $q) {
173         // Set hightlighting.
174         $query->setHighlight(true);
175         foreach ($this->highlightfields as $field) {
176             $query->addHighlightField($field);
177         }
178         $query->setHighlightFragsize(static::FRAG_SIZE);
179         $query->setHighlightSimplePre(self::HIGHLIGHT_START);
180         $query->setHighlightSimplePost(self::HIGHLIGHT_END);
181         $query->setHighlightMergeContiguous(true);
183         $query->setQuery($q);
185         // A reasonable max.
186         $query->setRows(\core_search\manager::MAX_RESULTS);
187     }
189     /**
190      * Sets fields to be returned in the result.
191      *
192      * @param SolrQuery $query object.
193      */
194     public function add_fields($query) {
195         $documentclass = $this->get_document_classname();
196         $fields = array_keys($documentclass::get_default_fields_definition());
197         foreach ($fields as $field) {
198             $query->addField($field);
199         }
200     }
202     /**
203      * Finds the key common to both highlighing and docs array returned from response.
204      * @param object $response containing results.
205      */
206     public function add_highlight_content($response) {
207         $highlightedobject = $response->highlighting;
208         foreach ($response->response->docs as $doc) {
209             $x = $doc->id;
210             $highlighteddoc = $highlightedobject->$x;
211             $this->merge_highlight_field_values($doc, $highlighteddoc);
212         }
213     }
215     /**
216      * Adds the highlighting array values to docs array values.
217      *
218      * @throws \core_search\engine_exception
219      * @param object $doc containing the results.
220      * @param object $highlighteddoc containing the highlighted results values.
221      */
222     public function merge_highlight_field_values($doc, $highlighteddoc) {
224         foreach ($this->highlightfields as $field) {
225             if (!empty($doc->$field)) {
227                 // Check that the returned value is not an array. No way we can make this work with multivalued solr fields.
228                 if (is_array($doc->{$field})) {
229                     throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $field);
230                 }
232                 if (!empty($highlighteddoc->$field)) {
233                     // Replace by the highlighted result.
234                     $doc->$field = reset($highlighteddoc->$field);
235                 }
236             }
237         }
238     }
240     /**
241      * Filters the response on Moodle side.
242      *
243      * @param object $queryresponse containing the response return from solr server.
244      * @return array $results containing final results to be displayed.
245      */
246     public function query_response($queryresponse) {
247         global $USER;
249         $userid = $USER->id;
250         $noownerid = \core_search\manager::NO_OWNER_ID;
252         $response = $queryresponse->getResponse();
253         $numgranted = 0;
255         if (!$docs = $response->response->docs) {
256             return array();
257         }
259         if (!empty($response->response->numFound)) {
260             $this->add_highlight_content($response);
262             // Iterate through the results checking its availability and whether they are available for the user or not.
263             foreach ($docs as $key => $docdata) {
264                 if ($docdata['owneruserid'] != $noownerid && $docdata['owneruserid'] != $userid) {
265                     // If owneruserid is set, no other user should be able to access this record.
266                     unset($docs[$key]);
267                     continue;
268                 }
270                 if (!$searcharea = $this->get_search_area($docdata->areaid)) {
271                     unset($docs[$key]);
272                     continue;
273                 }
275                 $docdata = $this->standarize_solr_obj($docdata);
277                 $access = $searcharea->check_access($docdata['itemid']);
278                 switch ($access) {
279                     case \core_search\manager::ACCESS_DELETED:
280                         $this->delete_by_id($docdata['id']);
281                         unset($docs[$key]);
282                         break;
283                     case \core_search\manager::ACCESS_DENIED:
284                         unset($docs[$key]);
285                         break;
286                     case \core_search\manager::ACCESS_GRANTED:
287                         $numgranted++;
289                         // Add the doc.
290                         $docs[$key] = $this->to_document($searcharea, $docdata);
291                         break;
292                 }
294                 // This should never happen.
295                 if ($numgranted >= \core_search\manager::MAX_RESULTS) {
296                     $docs = array_slice($docs, 0, \core_search\manager::MAX_RESULTS, true);
297                     break;
298                 }
299             }
300         }
302         return $docs;
303     }
305     /**
306      * Returns a standard php array from a \SolrObject instance.
307      *
308      * @param \SolrObject $obj
309      * @return array The returned document as an array.
310      */
311     public function standarize_solr_obj(\SolrObject $obj) {
312         $properties = $obj->getPropertyNames();
314         $docdata = array();
315         foreach($properties as $name) {
316             // http://php.net/manual/en/solrobject.getpropertynames.php#98018.
317             $name = trim($name);
318             $docdata[$name] = $obj->offsetGet($name);
319         }
320         return $docdata;
321     }
323     /**
324      * Adds a document to the search engine.
325      *
326      * This does not commit to the search engine.
327      *
328      * @param document $document
329      * @param bool     $fileindexing True if file indexing is to be used
330      * @return bool
331      */
332     public function add_document($document, $fileindexing = false) {
333         $docdata = $document->export_for_engine();
335         if (!$this->add_text_document($docdata)) {
336             return false;
337         }
339         return true;
340     }
342     /**
343      * Adds a text document to the search engine.
344      *
345      * @param array $filedoc
346      * @return bool
347      */
348     protected function add_text_document($doc) {
349         $solrdoc = new \SolrInputDocument();
350         foreach ($doc as $field => $value) {
351             $solrdoc->addField($field, $value);
352         }
354         try {
355             $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN);
356             return true;
357         } catch (\SolrClientException $e) {
358             debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER);
359         } catch (\SolrServerException $e) {
360             // We only use the first line of the message, as it's a fully java stacktrace behind it.
361             $msg = strtok($e->getMessage(), "\n");
362             debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER);
363         }
365         return false;
366     }
368     /**
369      * Commits all pending changes.
370      *
371      * @return void
372      */
373     protected function commit() {
374         $this->get_search_client()->commit();
375     }
377     /**
378      * Do any area cleanup needed, and do anything to confirm contents.
379      *
380      * Return false to prevent the search area completed time and stats from being updated.
381      *
382      * @param \core_search\area\base $searcharea The search area that was complete
383      * @param int $numdocs The number of documents that were added to the index
384      * @param bool $fullindex True if a full index is being performed
385      * @return bool True means that data is considered indexed
386      */
387     public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) {
388         $this->commit();
390         return true;
391     }
393     /**
394      * Defragments the index.
395      *
396      * @return void
397      */
398     public function optimize() {
399         $this->get_search_client()->optimize(1, true, false);
400     }
402     /**
403      * Deletes the specified document.
404      *
405      * @param string $id The document id to delete
406      * @return void
407      */
408     public function delete_by_id($id) {
409         $this->get_search_client()->deleteById($id);
410         $this->commit();
411     }
413     /**
414      * Delete all area's documents.
415      *
416      * @param string $areaid
417      * @return void
418      */
419     public function delete($areaid = null) {
420         if ($areaid) {
421             $this->get_search_client()->deleteByQuery('areaid:' . $areaid);
422         } else {
423             $this->get_search_client()->deleteByQuery('*:*');
424         }
425         $this->commit();
426     }
428     /**
429      * Pings the Solr server using search_solr config
430      *
431      * @return true|string Returns true if all good or an error string.
432      */
433     public function is_server_ready() {
435         if (empty($this->config->server_hostname) || empty($this->config->indexname)) {
436             return 'No solr configuration found';
437         }
439         if (!$this->client = $this->get_search_client(false)) {
440             return get_string('engineserverstatus', 'search');
441         }
443         try {
444             @$this->client->ping();
445         } catch (\SolrClientException $ex) {
446             return 'Solr client error: ' . $ex->getMessage();
447         } catch (\SolrServerException $ex) {
448             return 'Solr server error: ' . $ex->getMessage();
449         }
451         // Check that setup schema has already run.
452         try {
453             $schema = new \search_solr\schema();
454             $schema->validate_setup();
455         } catch (\moodle_exception $e) {
456             return $e->getMessage();
457         }
459         return true;
460     }
462     /**
463      * Checks if the PHP Solr extension is available.
464      *
465      * @return bool
466      */
467     public function is_installed() {
468         return function_exists('solr_get_version');
469     }
471     /**
472      * Returns the solr client instance.
473      *
474      * @throws \core_search\engine_exception
475      * @param bool $triggerexception
476      * @return \SolrClient
477      */
478     protected function get_search_client($triggerexception = true) {
480         // Type comparison as it is set to false if not available.
481         if ($this->client !== null) {
482             return $this->client;
483         }
485         $options = array(
486             'hostname' => $this->config->server_hostname,
487             'path'     => '/solr/' . $this->config->indexname,
488             'login'    => !empty($this->config->server_username) ? $this->config->server_username : '',
489             'password' => !empty($this->config->server_password) ? $this->config->server_password : '',
490             'port'     => !empty($this->config->server_port) ? $this->config->server_port : '',
491             'secure' => !empty($this->config->secure) ? true : false,
492             'ssl_cert' => !empty($this->config->ssl_cert) ? $this->config->ssl_cert : '',
493             'ssl_key' => !empty($this->config->ssl_key) ? $this->config->ssl_key : '',
494             'ssl_keypassword' => !empty($this->config->ssl_keypassword) ? $this->config->ssl_keypassword : '',
495             'ssl_cainfo' => !empty($this->config->ssl_cainfo) ? $this->config->ssl_cainfo : '',
496             'ssl_capath' => !empty($this->config->ssl_capath) ? $this->config->ssl_capath : '',
497             'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'
498         );
500         $this->client = new \SolrClient($options);
502         if ($this->client === false && $triggerexception) {
503             throw new \core_search\engine_exception('engineserverstatus', 'search');
504         }
506         return $this->client;
507     }
509     /**
510      * Returns a curl object for conntecting to solr.
511      *
512      * @return \curl
513      */
514     public function get_curl_object() {
515         if (!is_null($this->curl)) {
516             return $this->curl;
517         }
519         $this->curl = new \curl();
521         $options = array();
522         // Build the SSL options. Based on pecl-solr and general testing.
523         if (!empty($this->config->secure)) {
524             if (!empty($this->config->ssl_cert)) {
525                 $options['CURLOPT_SSLCERT'] = $this->config->ssl_cert;
526                 $options['CURLOPT_SSLCERTTYPE'] = 'PEM';
527             }
529             if (!empty($this->config->ssl_key)) {
530                 $options['CURLOPT_SSLKEY'] = $this->config->ssl_key;
531                 $options['CURLOPT_SSLKEYTYPE'] = 'PEM';
532             }
534             if (!empty($this->config->ssl_keypassword)) {
535                 $options['CURLOPT_KEYPASSWD'] = $this->config->ssl_keypassword;
536             }
538             if (!empty($this->config->ssl_cainfo)) {
539                 $options['CURLOPT_CAINFO'] = $this->config->ssl_cainfo;
540             }
542             if (!empty($this->config->ssl_capath)) {
543                 $options['CURLOPT_CAPATH'] = $this->config->ssl_capath;
544             }
545         }
547         $this->curl->setopt($options);
549         if (!empty($this->config->server_username) && !empty($this->config->server_password)) {
550             $authorization = $this->config->server_username . ':' . $this->config->server_password;
551             $this->curl->setHeader('Authorization', 'Basic ' . base64_encode($authorization));
552         }
554         return $this->curl;
555     }
557     /**
558      * Return a Moodle url object for the server connection.
559      *
560      * @param string $path The solr path to append.
561      * @return \moodle_url
562      */
563     public function get_connection_url($path) {
564         // Must use the proper protocol, or SSL will fail.
565         $protocol = !empty($this->config->secure) ? 'https' : 'http';
566         $url = $protocol . '://' . rtrim($this->config->server_hostname, '/');
567         if (!empty($this->config->server_port)) {
568             $url .= ':' . $this->config->server_port;
569         }
570         $url .= '/solr/' . $this->config->indexname . '/' . ltrim($path, '/');
572         return new \moodle_url($url);
573     }