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