47f244ea90a5c41d3209c85ffe40cf6700f61af5
[moodle.git] / search / engine / solr / tests / engine_test.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 earch engine base unit tests.
19  *
20  * Required params:
21  * - define('TEST_SEARCH_SOLR_HOSTNAME', '127.0.0.1');
22  * - define('TEST_SEARCH_SOLR_PORT', '8983');
23  * - define('TEST_SEARCH_SOLR_INDEXNAME', 'unittest');
24  *
25  * Optional params:
26  * - define('TEST_SEARCH_SOLR_USERNAME', '');
27  * - define('TEST_SEARCH_SOLR_PASSWORD', '');
28  * - define('TEST_SEARCH_SOLR_SSLCERT', '');
29  * - define('TEST_SEARCH_SOLR_SSLKEY', '');
30  * - define('TEST_SEARCH_SOLR_KEYPASSWORD', '');
31  * - define('TEST_SEARCH_SOLR_CAINFOCERT', '');
32  *
33  * @package     search_solr
34  * @category    phpunit
35  * @copyright   2015 David Monllao {@link http://www.davidmonllao.com}
36  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
39 defined('MOODLE_INTERNAL') || die();
41 global $CFG;
42 require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
43 require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php');
44 require_once($CFG->dirroot . '/search/engine/solr/tests/fixtures/testable_engine.php');
46 /**
47  * Solr search engine base unit tests.
48  *
49  * @package     search_solr
50  * @category    phpunit
51  * @copyright   2015 David Monllao {@link http://www.davidmonllao.com}
52  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
53  */
54 class search_solr_engine_testcase extends advanced_testcase {
56     /**
57      * @var \core_search\manager
58      */
59     protected $search = null;
61     /**
62      * @var Instace of core_search_generator.
63      */
64     protected $generator = null;
66     /**
67      * @var Instace of testable_engine.
68      */
69     protected $engine = null;
71     public function setUp() {
72         $this->resetAfterTest();
73         set_config('enableglobalsearch', true);
74         set_config('searchengine', 'solr');
76         if (!function_exists('solr_get_version')) {
77             $this->markTestSkipped('Solr extension is not loaded.');
78         }
80         if (!defined('TEST_SEARCH_SOLR_HOSTNAME') || !defined('TEST_SEARCH_SOLR_INDEXNAME') ||
81                 !defined('TEST_SEARCH_SOLR_PORT')) {
82             $this->markTestSkipped('Solr extension test server not set.');
83         }
85         set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr');
86         set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr');
87         set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr');
89         if (defined('TEST_SEARCH_SOLR_USERNAME')) {
90             set_config('server_username', TEST_SEARCH_SOLR_USERNAME, 'search_solr');
91         }
93         if (defined('TEST_SEARCH_SOLR_PASSWORD')) {
94             set_config('server_password', TEST_SEARCH_SOLR_PASSWORD, 'search_solr');
95         }
97         if (defined('TEST_SEARCH_SOLR_SSLCERT')) {
98             set_config('secure', true, 'search_solr');
99             set_config('ssl_cert', TEST_SEARCH_SOLR_SSLCERT, 'search_solr');
100         }
102         if (defined('TEST_SEARCH_SOLR_SSLKEY')) {
103             set_config('ssl_key', TEST_SEARCH_SOLR_SSLKEY, 'search_solr');
104         }
106         if (defined('TEST_SEARCH_SOLR_KEYPASSWORD')) {
107             set_config('ssl_keypassword', TEST_SEARCH_SOLR_KEYPASSWORD, 'search_solr');
108         }
110         if (defined('TEST_SEARCH_SOLR_CAINFOCERT')) {
111             set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr');
112         }
114         set_config('fileindexing', 1, 'search_solr');
116         // We are only test indexing small string files, so setting this as low as we can.
117         set_config('maxindexfilekb', 1, 'search_solr');
119         $this->generator = self::getDataGenerator()->get_plugin_generator('core_search');
120         $this->generator->setup();
122         // Inject search solr engine into the testable core search as we need to add the mock
123         // search component to it.
124         $this->engine = new \search_solr\testable_engine();
125         $this->search = testable_core_search::instance($this->engine);
126         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
127         $this->search->add_search_area($areaid, new core_mocksearch\search\mock_search_area());
129         $this->setAdminUser();
131         // Cleanup before doing anything on it as the index it is out of this test control.
132         $this->search->delete_index();
134         // Add moodle fields if they don't exist.
135         $schema = new \search_solr\schema();
136         $schema->setup(false);
137     }
139     public function tearDown() {
140         // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup.
141         if ($this->generator) {
142             // Moodle DML freaks out if we don't teardown the temp table after each run.
143             $this->generator->teardown();
144             $this->generator = null;
145         }
146     }
148     /**
149      * Simple data provider to allow tests to be run with file indexing on and off.
150      */
151     public function file_indexing_provider() {
152         return array(
153             'file-indexing-on' => array(1),
154             'file-indexing-off' => array(0)
155         );
156     }
158     public function test_connection() {
159         $this->assertTrue($this->engine->is_server_ready());
160     }
162     /**
163      * @dataProvider file_indexing_provider
164      */
165     public function test_index($fileindexing) {
166         global $DB;
168         $this->engine->test_set_config('fileindexing', $fileindexing);
170         $record = new \stdClass();
171         $record->timemodified = time() - 1;
172         $this->generator->create_record($record);
174         // Data gets into the search engine.
175         $this->assertTrue($this->search->index());
177         // Not anymore as everything was already added.
178         sleep(1);
179         $this->assertFalse($this->search->index());
181         $this->generator->create_record();
183         // Indexing again once there is new data.
184         $this->assertTrue($this->search->index());
185     }
187     /**
188      * Better keep this not very strict about which or how many results are returned as may depend on solr engine config.
189      *
190      * @dataProvider file_indexing_provider
191      *
192      * @return void
193      */
194     public function test_search($fileindexing) {
195         global $USER, $DB;
197         $this->engine->test_set_config('fileindexing', $fileindexing);
199         $this->generator->create_record();
200         $record = new \stdClass();
201         $record->title = "Special title";
202         $this->generator->create_record($record);
204         $this->search->index();
206         $querydata = new stdClass();
207         $querydata->q = 'message';
208         $results = $this->search->search($querydata);
209         $this->assertCount(2, $results);
211         // Based on core_mocksearch\search\indexer.
212         $this->assertEquals($USER->id, $results[0]->get('userid'));
213         $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid'));
215         // Do a test to make sure we aren't searching non-query fields, like areaid.
216         $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
217         $this->assertCount(0, $this->search->search($querydata));
218         $querydata->q = 'message';
220         sleep(1);
221         $beforeadding = time();
222         sleep(1);
223         $this->generator->create_record();
224         $this->search->index();
226         // Timestart.
227         $querydata->timestart = $beforeadding;
228         $this->assertCount(1, $this->search->search($querydata));
230         // Timeend.
231         unset($querydata->timestart);
232         $querydata->timeend = $beforeadding;
233         $this->assertCount(2, $this->search->search($querydata));
235         // Title.
236         unset($querydata->timeend);
237         $querydata->title = 'Special title';
238         $this->assertCount(1, $this->search->search($querydata));
240         // Course IDs.
241         unset($querydata->title);
242         $querydata->courseids = array(SITEID + 1);
243         $this->assertCount(0, $this->search->search($querydata));
245         $querydata->courseids = array(SITEID);
246         $this->assertCount(3, $this->search->search($querydata));
248         // Now try some area-id combinations.
249         unset($querydata->courseids);
250         $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post');
251         $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
253         $querydata->areaids = array($forumpostareaid);
254         $this->assertCount(0, $this->search->search($querydata));
256         $querydata->areaids = array($forumpostareaid, $mockareaid);
257         $this->assertCount(3, $this->search->search($querydata));
259         $querydata->areaids = array($mockareaid);
260         $this->assertCount(3, $this->search->search($querydata));
262         $querydata->areaids = array();
263         $this->assertCount(3, $this->search->search($querydata));
265         // Check that index contents get updated.
266         $this->generator->delete_all();
267         $this->search->index(true);
268         unset($querydata->title);
269         $querydata->q = '*';
270         $this->assertCount(0, $this->search->search($querydata));
271     }
273     /**
274      * @dataProvider file_indexing_provider
275      */
276     public function test_delete($fileindexing) {
277         $this->engine->test_set_config('fileindexing', $fileindexing);
279         $this->generator->create_record();
280         $this->generator->create_record();
281         $this->search->index();
283         $querydata = new stdClass();
284         $querydata->q = 'message';
286         $this->assertCount(2, $this->search->search($querydata));
288         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
289         $this->search->delete_index($areaid);
290         $this->assertCount(0, $this->search->search($querydata));
291     }
293     /**
294      * @dataProvider file_indexing_provider
295      */
296     public function test_alloweduserid($fileindexing) {
297         $this->engine->test_set_config('fileindexing', $fileindexing);
299         $area = new core_mocksearch\search\mock_search_area();
301         $record = $this->generator->create_record();
303         // Get the doc and insert the default doc.
304         $doc = $area->get_document($record);
305         $this->engine->add_document($doc);
307         $users = array();
308         $users[] = $this->getDataGenerator()->create_user();
309         $users[] = $this->getDataGenerator()->create_user();
310         $users[] = $this->getDataGenerator()->create_user();
312         // Add a record that only user 100 can see.
313         $originalid = $doc->get('id');
315         // Now add a custom doc for each user.
316         foreach ($users as $user) {
317             $doc = $area->get_document($record);
318             $doc->set('id', $originalid.'-'.$user->id);
319             $doc->set('owneruserid', $user->id);
320             $this->engine->add_document($doc);
321         }
323         $this->engine->area_index_complete($area->get_area_id());
325         $querydata = new stdClass();
326         $querydata->q = 'message';
327         $querydata->title = $doc->get('title');
329         // We are going to go through each user and see if they get the original and the owned doc.
330         foreach ($users as $user) {
331             $this->setUser($user);
333             $results = $this->search->search($querydata);
334             $this->assertCount(2, $results);
336             $owned = 0;
337             $notowned = 0;
339             // We don't know what order we will get the results in, so we are doing this.
340             foreach ($results as $result) {
341                 $owneruserid = $result->get('owneruserid');
342                 if (empty($owneruserid)) {
343                     $notowned++;
344                     $this->assertEquals(0, $owneruserid);
345                     $this->assertEquals($originalid, $result->get('id'));
346                 } else {
347                     $owned++;
348                     $this->assertEquals($user->id, $owneruserid);
349                     $this->assertEquals($originalid.'-'.$user->id, $result->get('id'));
350                 }
351             }
353             $this->assertEquals(1, $owned);
354             $this->assertEquals(1, $notowned);
355         }
357         // Now test a user with no owned results.
358         $otheruser = $this->getDataGenerator()->create_user();
359         $this->setUser($otheruser);
361         $results = $this->search->search($querydata);
362         $this->assertCount(1, $results);
364         $this->assertEquals(0, $results[0]->get('owneruserid'));
365         $this->assertEquals($originalid, $results[0]->get('id'));
366     }
368     /**
369      * @dataProvider file_indexing_provider
370      */
371     public function test_highlight($fileindexing) {
372         global $PAGE;
374         $this->engine->test_set_config('fileindexing', $fileindexing);
376         $this->generator->create_record();
377         $this->search->index();
379         $querydata = new stdClass();
380         $querydata->q = 'message';
382         $results = $this->search->search($querydata);
383         $this->assertCount(1, $results);
385         $result = reset($results);
387         $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|';
388         $this->assertRegExp($regex, $result->get('content'));
390         $searchrenderer = $PAGE->get_renderer('core_search');
391         $exported = $result->export_for_template($searchrenderer);
393         $regex = '|<span class="highlight">message</span>|';
394         $this->assertRegExp($regex, $exported['content']);
395     }
397     public function test_export_file_for_engine() {
398         // Get area to work with.
399         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
400         $area = \core_search\manager::get_search_area($areaid);
402         $record = $this->generator->create_record();
404         $doc = $area->get_document($record);
405         $filerecord = new stdClass();
406         $filerecord->timemodified  = 978310800;
407         $file = $this->generator->create_file($filerecord);
408         $doc->add_stored_file($file);
410         $filearray = $doc->export_file_for_engine($file);
412         $this->assertEquals(\core_search\manager::TYPE_FILE, $filearray['type']);
413         $this->assertEquals($file->get_id(), $filearray['solr_fileid']);
414         $this->assertEquals($file->get_contenthash(), $filearray['solr_filecontenthash']);
415         $this->assertEquals(\search_solr\document::INDEXED_FILE_TRUE, $filearray['solr_fileindexstatus']);
416         $this->assertEquals($file->get_filename(), $filearray['title']);
417         $this->assertEquals(978310800, \search_solr\document::import_time_from_engine($filearray['modified']));
418     }
420     public function test_index_file() {
421         // Very simple test.
422         $file = $this->generator->create_file();
424         $record = new \stdClass();
425         $record->attachfileids = array($file->get_id());
426         $this->generator->create_record($record);
428         $this->search->index();
429         $querydata = new stdClass();
430         $querydata->q = '"File contents"';
432         $this->assertCount(1, $this->search->search($querydata));
433     }
435     public function test_reindexing_files() {
436         // Get area to work with.
437         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
438         $area = \core_search\manager::get_search_area($areaid);
440         $record = $this->generator->create_record();
442         $doc = $area->get_document($record);
444         // Now we are going to make some files.
445         $fs = get_file_storage();
446         $syscontext = \context_system::instance();
448         $files = array();
450         $filerecord = new \stdClass();
451         // We make enough so that we pass the 500 files threashold. That is the boundary when getting files.
452         $boundary = 500;
453         $top = (int)($boundary * 1.1);
454         for ($i = 0; $i < $top; $i++) {
455             $filerecord->filename  = 'searchfile'.$i;
456             $filerecord->content = 'Some FileContents'.$i;
457             $file = $this->generator->create_file($filerecord);
458             $doc->add_stored_file($file);
459             $files[] = $file;
460         }
462         // Add the doc with lots of files, then commit.
463         $this->engine->add_document($doc, true);
464         $this->engine->area_index_complete($area->get_area_id());
466         // Indexes we are going to check. 0 means we will delete, 1 means we will keep.
467         $checkfiles = array(
468             0 => 0,                        // Check the begining of the set.
469             1 => 1,
470             2 => 0,
471             ($top - 3) => 0,               // Check the end of the set.
472             ($top - 2) => 1,
473             ($top - 1) => 0,
474             ($boundary - 2) => 0,          // Check at the boundary between fetch groups.
475             ($boundary - 1) => 0,
476             $boundary => 0,
477             ($boundary + 1) => 0,
478             ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones.
479             ((int)($boundary * 1.05)) => 1
480         );
482         $querydata = new stdClass();
484         // First, check that all the files are currently there.
485         foreach ($checkfiles as $key => $unused) {
486             $querydata->q = 'FileContents'.$key;
487             $this->assertCount(1, $this->search->search($querydata));
488             $querydata->q = 'searchfile'.$key;
489             $this->assertCount(1, $this->search->search($querydata));
490         }
492         // Remove the files we want removed from the files array.
493         foreach ($checkfiles as $key => $keep) {
494             if (!$keep) {
495                 unset($files[$key]);
496             }
497         }
499         // And make us a new file to add.
500         $filerecord->filename  = 'searchfileNew';
501         $filerecord->content  = 'Some FileContentsNew';
502         $files[] = $this->generator->create_file($filerecord);
503         $checkfiles['New'] = 1;
505         $doc = $area->get_document($record);
506         foreach($files as $file) {
507             $doc->add_stored_file($file);
508         }
510         // Reindex the document with the changed files.
511         $this->engine->add_document($doc, true);
512         $this->engine->area_index_complete($area->get_area_id());
514         // Go through our check array, and see if the file is there or not.
515         foreach ($checkfiles as $key => $keep) {
516             $querydata->q = 'FileContents'.$key;
517             $this->assertCount($keep, $this->search->search($querydata));
518             $querydata->q = 'searchfile'.$key;
519             $this->assertCount($keep, $this->search->search($querydata));
520         }
522         // Now check that we get one result when we search from something in all of them.
523         $querydata->q = 'Some';
524         $this->assertCount(1, $this->search->search($querydata));
525     }
527     /**
528      * Test indexing a file we don't consider indexable.
529      */
530     public function test_index_filtered_file() {
531         // Get area to work with.
532         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
533         $area = \core_search\manager::get_search_area($areaid);
535         // Get a single record to make a doc from.
536         $record = $this->generator->create_record();
538         $doc = $area->get_document($record);
540         // Now we are going to make some files.
541         $fs = get_file_storage();
542         $syscontext = \context_system::instance();
544         // We need to make a file greater than 1kB in size, which is the lowest filter size.
545         $filerecord = new \stdClass();
546         $filerecord->filename = 'largefile';
547         $filerecord->content = 'Some LargeFindContent to find.';
548         for ($i = 0; $i < 200; $i++) {
549             $filerecord->content .= ' The quick brown fox jumps over the lazy dog.';
550         }
552         $this->assertGreaterThan(1024, strlen($filerecord->content));
554         $file = $this->generator->create_file($filerecord);
555         $doc->add_stored_file($file);
557         $filerecord->filename = 'smallfile';
558         $filerecord->content = 'Some SmallFindContent to find.';
559         $file = $this->generator->create_file($filerecord);
560         $doc->add_stored_file($file);
562         $this->engine->add_document($doc, true);
563         $this->engine->area_index_complete($area->get_area_id());
565         $querydata = new stdClass();
566         // We shouldn't be able to find the large file contents.
567         $querydata->q = 'LargeFindContent';
568         $this->assertCount(0, $this->search->search($querydata));
570         // But we should be able to find the filename.
571         $querydata->q = 'largefile';
572         $this->assertCount(1, $this->search->search($querydata));
574         // We should be able to find the small file contents.
575         $querydata->q = 'SmallFindContent';
576         $this->assertCount(1, $this->search->search($querydata));
578         // And we should be able to find the filename.
579         $querydata->q = 'smallfile';
580         $this->assertCount(1, $this->search->search($querydata));
581     }
583     public function test_delete_by_id() {
584         // First get files in the index.
585         $file = $this->generator->create_file();
586         $record = new \stdClass();
587         $record->attachfileids = array($file->get_id());
588         $this->generator->create_record($record);
589         $this->generator->create_record($record);
590         $this->search->index();
592         $querydata = new stdClass();
594         // Then search to make sure they are there.
595         $querydata->q = '"File contents"';
596         $results = $this->search->search($querydata);
597         $this->assertCount(2, $results);
599         $first = reset($results);
600         $deleteid = $first->get('id');
602         $this->engine->delete_by_id($deleteid);
604         // Check that we don't get a result for it anymore.
605         $results = $this->search->search($querydata);
606         $this->assertCount(1, $results);
607         $result = reset($results);
608         $this->assertNotEquals($deleteid, $result->get('id'));
609     }
611     /**
612      * Test that expected results are returned, even with low check_access success rate.
613      *
614      * @dataProvider file_indexing_provider
615      */
616     public function test_solr_filling($fileindexing) {
617         $this->engine->test_set_config('fileindexing', $fileindexing);
619         $user1 = self::getDataGenerator()->create_user();
620         $user2 = self::getDataGenerator()->create_user();
622         // We are going to create a bunch of records that user 1 can see with 2 keywords.
623         // Then we are going to create a bunch for user 2 with only 1 of the keywords.
624         // If user 2 searches for both keywords, solr will return all of the user 1 results, then the user 2 results.
625         // This is because the user 1 results will match 2 keywords, while the others will match only 1.
627         $record = new \stdClass();
629         // First create a bunch of records for user 1 to see.
630         $record->denyuserids = array($user2->id);
631         $record->content = 'Something1 Something2';
632         $maxresults = (int)(\core_search\manager::MAX_RESULTS * .75);
633         for ($i = 0; $i < $maxresults; $i++) {
634             $this->generator->create_record($record);
635         }
637         // Then create a bunch of records for user 2 to see.
638         $record->denyuserids = array($user1->id);
639         $record->content = 'Something1';
640         for ($i = 0; $i < $maxresults; $i++) {
641             $this->generator->create_record($record);
642         }
644         $this->search->index();
646         // Check that user 1 sees all their results.
647         $this->setUser($user1);
648         $querydata = new stdClass();
649         $querydata->q = 'Something1 Something2';
650         $results = $this->search->search($querydata);
651         $this->assertCount($maxresults, $results);
653         // Check that user 2 will see theirs, even though they may be crouded out.
654         $this->setUser($user2);
655         $results = $this->search->search($querydata);
656         $this->assertCount($maxresults, $results);
657     }
659     /**
660      * Create 40 docs, that will be return from Solr in 10 hidden, 10 visible, 10 hidden, 10 visible if you query for:
661      * Something1 Something2 Something3 Something4, with the specified user set.
662      */
663     protected function setup_user_hidden_docs($user) {
664         // These results will come first, and will not be visible by the user.
665         $record = new \stdClass();
666         $record->denyuserids = array($user->id);
667         $record->content = 'Something1 Something2 Something3 Something4';
668         for ($i = 0; $i < 10; $i++) {
669             $this->generator->create_record($record);
670         }
672         // These results will come second, and will  be visible by the user.
673         unset($record->denyuserids);
674         $record->content = 'Something1 Something2 Something3';
675         for ($i = 0; $i < 10; $i++) {
676             $this->generator->create_record($record);
677         }
679         // These results will come third, and will not be visible by the user.
680         $record->denyuserids = array($user->id);
681         $record->content = 'Something1 Something2';
682         for ($i = 0; $i < 10; $i++) {
683             $this->generator->create_record($record);
684         }
686         // These results will come fourth, and will be visible by the user.
687         unset($record->denyuserids);
688         $record->content = 'Something1 ';
689         for ($i = 0; $i < 10; $i++) {
690             $this->generator->create_record($record);
691         }
692     }
694     /**
695      * Test that counts are what we expect.
696      *
697      * @dataProvider file_indexing_provider
698      */
699     public function test_get_query_total_count($fileindexing) {
700         $this->engine->test_set_config('fileindexing', $fileindexing);
702         $user = self::getDataGenerator()->create_user();
703         $this->setup_user_hidden_docs($user);
704         $this->search->index();
706         $this->setUser($user);
707         $querydata = new stdClass();
708         $querydata->q = 'Something1 Something2 Something3 Something4';
710         // In this first set, it should have determined the first 10 of 40 are bad, so there could be up to 30 left.
711         $results = $this->engine->execute_query($querydata, (object)['everything' => true], 5);
712         $this->assertEquals(30, $this->engine->get_query_total_count());
713         $this->assertCount(5, $results);
715         // To get to 15, it has to process the first 10 that are bad, 10 that are good, 10 that are bad, then 5 that are good.
716         // So we now know 20 are bad out of 40.
717         $results = $this->engine->execute_query($querydata, (object)['everything' => true], 15);
718         $this->assertEquals(20, $this->engine->get_query_total_count());
719         $this->assertCount(15, $results);
721         // Try to get more then all, make sure we still see 20 count and 20 returned.
722         $results = $this->engine->execute_query($querydata, (object)['everything' => true], 30);
723         $this->assertEquals(20, $this->engine->get_query_total_count());
724         $this->assertCount(20, $results);
725     }
727     /**
728      * Test that paged results are what we expect.
729      *
730      * @dataProvider file_indexing_provider
731      */
732     public function test_manager_paged_search($fileindexing) {
733         $this->engine->test_set_config('fileindexing', $fileindexing);
735         $user = self::getDataGenerator()->create_user();
736         $this->setup_user_hidden_docs($user);
737         $this->search->index();
739         // Check that user 1 sees all their results.
740         $this->setUser($user);
741         $querydata = new stdClass();
742         $querydata->q = 'Something1 Something2 Something3 Something4';
744         // On this first page, it should have determined the first 10 of 40 are bad, so there could be up to 30 left.
745         $results = $this->search->paged_search($querydata, 0);
746         $this->assertEquals(30, $results->totalcount);
747         $this->assertCount(10, $results->results);
748         $this->assertEquals(0, $results->actualpage);
750         // On the second page, it should have found the next 10 bad ones, so we no know there are only 20 total.
751         $results = $this->search->paged_search($querydata, 1);
752         $this->assertEquals(20, $results->totalcount);
753         $this->assertCount(10, $results->results);
754         $this->assertEquals(1, $results->actualpage);
756         // Try to get an additional page - we should get back page 1 results, since that is the last page with valid results.
757         $results = $this->search->paged_search($querydata, 2);
758         $this->assertEquals(20, $results->totalcount);
759         $this->assertCount(10, $results->results);
760         $this->assertEquals(1, $results->actualpage);
761     }
763     /**
764      * Tests searching for results restricted to context id.
765      */
766     public function test_context_restriction() {
767         // Use real search areas.
768         $this->search->clear_static();
769         $this->search->add_core_search_areas();
771         // Create 2 courses and some forums.
772         $generator = $this->getDataGenerator();
773         $course1 = $generator->create_course(['fullname' => 'Course 1', 'summary' => 'xyzzy']);
774         $contextc1 = \context_course::instance($course1->id);
775         $course1forum1 = $generator->create_module('forum', ['course' => $course1,
776                 'name' => 'C1F1', 'intro' => 'xyzzy']);
777         $contextc1f1 = \context_module::instance($course1forum1->cmid);
778         $course1forum2 = $generator->create_module('forum', ['course' => $course1,
779                 'name' => 'C1F2', 'intro' => 'xyzzy']);
780         $contextc1f2 = \context_module::instance($course1forum2->cmid);
781         $course2 = $generator->create_course(['fullname' => 'Course 2', 'summary' => 'xyzzy']);
782         $contextc2 = \context_course::instance($course1->id);
783         $course2forum = $generator->create_module('forum', ['course' => $course2,
784                 'name' => 'C2F', 'intro' => 'xyzzy']);
785         $contextc2f = \context_module::instance($course2forum->cmid);
787         // Index the courses and forums.
788         $this->search->index();
790         // Search as admin user should find everything.
791         $querydata = new stdClass();
792         $querydata->q = 'xyzzy';
793         $results = $this->search->search($querydata);
794         $this->assert_result_titles(
795                 ['Course 1', 'Course 2', 'C1F1', 'C1F2', 'C2F'], $results);
797         // Admin user manually restricts results by context id to include one course and one forum.
798         $querydata->contextids = [$contextc2f->id, $contextc1->id];
799         $results = $this->search->search($querydata);
800         $this->assert_result_titles(['Course 1', 'C2F'], $results);
802         // Student enrolled in only one course, same restriction, only has the available results.
803         $student2 = $generator->create_user();
804         $generator->enrol_user($student2->id, $course2->id, 'student');
805         $this->setUser($student2);
806         $results = $this->search->search($querydata);
807         $this->assert_result_titles(['C2F'], $results);
809         // Student enrolled in both courses, same restriction, same results as admin.
810         $student1 = $generator->create_user();
811         $generator->enrol_user($student1->id, $course1->id, 'student');
812         $generator->enrol_user($student1->id, $course2->id, 'student');
813         $this->setUser($student1);
814         $results = $this->search->search($querydata);
815         $this->assert_result_titles(['Course 1', 'C2F'], $results);
817         // Restrict both course and context.
818         $querydata->courseids = [$course2->id];
819         $results = $this->search->search($querydata);
820         $this->assert_result_titles(['C2F'], $results);
821         unset($querydata->courseids);
823         // Restrict both area and context.
824         $querydata->areaids = ['core_course-course'];
825         $results = $this->search->search($querydata);
826         $this->assert_result_titles(['Course 1'], $results);
828         // Restrict area and context, incompatibly - this has no results (and doesn't do a query).
829         $querydata->contextids = [$contextc2f->id];
830         $results = $this->search->search($querydata);
831         $this->assert_result_titles([], $results);
832     }
834     /**
835      * Tests searching for results in groups, either by specified group ids or based on user
836      * access permissions.
837      */
838     public function test_groups() {
839         global $USER;
841         // Use real search areas.
842         $this->search->clear_static();
843         $this->search->add_core_search_areas();
845         // Create 2 courses and a selection of forums with different group mode.
846         $generator = $this->getDataGenerator();
847         $course1 = $generator->create_course(['fullname' => 'Course 1']);
848         $forum1nogroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => NOGROUPS]);
849         $forum1separategroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => SEPARATEGROUPS]);
850         $forum1visiblegroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => VISIBLEGROUPS]);
851         $course2 = $generator->create_course(['fullname' => 'Course 2']);
852         $forum2separategroups = $generator->create_module('forum', ['course' => $course2, 'groupmode' => SEPARATEGROUPS]);
854         // Create two groups on each course.
855         $group1a = $generator->create_group(['courseid' => $course1->id]);
856         $group1b = $generator->create_group(['courseid' => $course1->id]);
857         $group2a = $generator->create_group(['courseid' => $course2->id]);
858         $group2b = $generator->create_group(['courseid' => $course2->id]);
860         // Create search records in each activity and (where relevant) in each group.
861         $forumgenerator = $generator->get_plugin_generator('mod_forum');
862         $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
863                 'forum' => $forum1nogroups->id, 'name' => 'F1NG', 'message' => 'xyzzy']);
864         $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
865                 'forum' => $forum1separategroups->id, 'name' => 'F1SG-A',  'message' => 'xyzzy',
866                 'groupid' => $group1a->id]);
867         $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
868                 'forum' => $forum1separategroups->id, 'name' => 'F1SG-B', 'message' => 'xyzzy',
869                 'groupid' => $group1b->id]);
870         $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
871                 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-A', 'message' => 'xyzzy',
872                 'groupid' => $group1a->id]);
873         $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id,
874                 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-B', 'message' => 'xyzzy',
875                 'groupid' => $group1b->id]);
876         $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id,
877                 'forum' => $forum2separategroups->id, 'name' => 'F2SG-A', 'message' => 'xyzzy',
878                 'groupid' => $group2a->id]);
879         $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id,
880                 'forum' => $forum2separategroups->id, 'name' => 'F2SG-B', 'message' => 'xyzzy',
881                 'groupid' => $group2b->id]);
883         $this->search->index();
885         // Search as admin user should find everything.
886         $querydata = new stdClass();
887         $querydata->q = 'xyzzy';
888         $results = $this->search->search($querydata);
889         $this->assert_result_titles(
890                 ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results);
892         // Admin user manually restricts results by groups.
893         $querydata->groupids = [$group1b->id, $group2a->id];
894         $results = $this->search->search($querydata);
895         $this->assert_result_titles(['F1SG-B', 'F1VG-B', 'F2SG-A'], $results);
897         // Student enrolled in both courses but no groups.
898         $student1 = $generator->create_user();
899         $generator->enrol_user($student1->id, $course1->id, 'student');
900         $generator->enrol_user($student1->id, $course2->id, 'student');
901         $this->setUser($student1);
903         unset($querydata->groupids);
904         $results = $this->search->search($querydata);
905         $this->assert_result_titles(['F1NG', 'F1VG-A', 'F1VG-B'], $results);
907         // Student enrolled in both courses and group A in both cases.
908         $student2 = $generator->create_user();
909         $generator->enrol_user($student2->id, $course1->id, 'student');
910         $generator->enrol_user($student2->id, $course2->id, 'student');
911         groups_add_member($group1a, $student2);
912         groups_add_member($group2a, $student2);
913         $this->setUser($student2);
915         $results = $this->search->search($querydata);
916         $this->assert_result_titles(['F1NG', 'F1SG-A', 'F1VG-A', 'F1VG-B', 'F2SG-A'], $results);
918         // Manually restrict results to group B in course 1.
919         $querydata->groupids = [$group1b->id];
920         $results = $this->search->search($querydata);
921         $this->assert_result_titles(['F1VG-B'], $results);
923         // Manually restrict results to group A in course 1.
924         $querydata->groupids = [$group1a->id];
925         $results = $this->search->search($querydata);
926         $this->assert_result_titles(['F1SG-A', 'F1VG-A'], $results);
928         // Manager enrolled in both courses (has access all groups).
929         $manager = $generator->create_user();
930         $generator->enrol_user($manager->id, $course1->id, 'manager');
931         $generator->enrol_user($manager->id, $course2->id, 'manager');
932         $this->setUser($manager);
933         unset($querydata->groupids);
934         $results = $this->search->search($querydata);
935         $this->assert_result_titles(
936                 ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results);
937     }
939     /**
940      * Tests searching for results restricted to specific user id(s).
941      */
942     public function test_user_restriction() {
943         // Use real search areas.
944         $this->search->clear_static();
945         $this->search->add_core_search_areas();
947         // Create a course, a forum, and a glossary.
948         $generator = $this->getDataGenerator();
949         $course = $generator->create_course();
950         $forum = $generator->create_module('forum', ['course' => $course->id]);
951         $glossary = $generator->create_module('glossary', ['course' => $course->id]);
953         // Create 3 user accounts, all enrolled as students on the course.
954         $user1 = $generator->create_user();
955         $user2 = $generator->create_user();
956         $user3 = $generator->create_user();
957         $generator->enrol_user($user1->id, $course->id, 'student');
958         $generator->enrol_user($user2->id, $course->id, 'student');
959         $generator->enrol_user($user3->id, $course->id, 'student');
961         // All users create a forum discussion.
962         $forumgen = $generator->get_plugin_generator('mod_forum');
963         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
964             'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']);
965         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
966                 'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']);
967         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
968                 'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']);
970         // Two of the users create entries in the glossary.
971         $glossarygen = $generator->get_plugin_generator('mod_glossary');
972         $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh',
973                 'userid' => $user1->id]);
974         $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh',
975                 'userid' => $user3->id]);
977         // Index the data.
978         $this->search->index();
980         // Search without user restriction should find everything.
981         $querydata = new stdClass();
982         $querydata->q = 'plugh';
983         $results = $this->search->search($querydata);
984         $this->assert_result_titles(
985                 ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results);
987         // Restriction to user 3 only.
988         $querydata->userids = [$user3->id];
989         $results = $this->search->search($querydata);
990         $this->assert_result_titles(
991                 ['Entry3', 'Post3'], $results);
993         // Restriction to users 1 and 2.
994         $querydata->userids = [$user1->id, $user2->id];
995         $results = $this->search->search($querydata);
996         $this->assert_result_titles(
997                 ['Entry1', 'Post1', 'Post2'], $results);
999         // Restriction to users 1 and 2 combined with context restriction.
1000         $querydata->contextids = [context_module::instance($glossary->cmid)->id];
1001         $results = $this->search->search($querydata);
1002         $this->assert_result_titles(
1003                 ['Entry1'], $results);
1005         // Restriction to users 1 and 2 combined with area restriction.
1006         unset($querydata->contextids);
1007         $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')];
1008         $results = $this->search->search($querydata);
1009         $this->assert_result_titles(
1010                 ['Post1', 'Post2'], $results);
1011     }
1013     /**
1014      * Tests searching for results containing words in italic text. (This used to fail.)
1015      */
1016     public function test_italics() {
1017         global $USER;
1019         // Use real search areas.
1020         $this->search->clear_static();
1021         $this->search->add_core_search_areas();
1023         // Create a course and a forum.
1024         $generator = $this->getDataGenerator();
1025         $course = $generator->create_course();
1026         $forum = $generator->create_module('forum', ['course' => $course->id]);
1028         // As admin user, create forum discussions with various words in italics or with underlines.
1029         $this->setAdminUser();
1030         $forumgen = $generator->get_plugin_generator('mod_forum');
1031         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1032                 'userid' => $USER->id, 'name' => 'Post1',
1033                 'message' => '<p>This is a post about <i>frogs</i>.</p>']);
1034         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1035                 'userid' => $USER->id, 'name' => 'Post2',
1036                 'message' => '<p>This is a post about <i>toads and zombies</i>.</p>']);
1037         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1038                 'userid' => $USER->id, 'name' => 'Post3',
1039                 'message' => '<p>This is a post about toads_and_zombies.</p>']);
1040         $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
1041                 'userid' => $USER->id, 'name' => 'Post4',
1042                 'message' => '<p>This is a post about _leading and trailing_ underlines.</p>']);
1044         // Index the data.
1045         $this->search->index();
1047         // Search for 'frogs' should find the post.
1048         $querydata = new stdClass();
1049         $querydata->q = 'frogs';
1050         $results = $this->search->search($querydata);
1051         $this->assert_result_titles(['Post1'], $results);
1053         // Search for 'toads' or 'zombies' should find post 2 (and not 3)...
1054         $querydata->q = 'toads';
1055         $results = $this->search->search($querydata);
1056         $this->assert_result_titles(['Post2'], $results);
1057         $querydata->q = 'zombies';
1058         $results = $this->search->search($querydata);
1059         $this->assert_result_titles(['Post2'], $results);
1061         // Search for 'toads_and_zombies' should find post 3.
1062         $querydata->q = 'toads_and_zombies';
1063         $results = $this->search->search($querydata);
1064         $this->assert_result_titles(['Post3'], $results);
1066         // Search for '_leading' or 'trailing_' should find post 4.
1067         $querydata->q = '_leading';
1068         $results = $this->search->search($querydata);
1069         $this->assert_result_titles(['Post4'], $results);
1070         $querydata->q = 'trailing_';
1071         $results = $this->search->search($querydata);
1072         $this->assert_result_titles(['Post4'], $results);
1073     }
1075     /**
1076      * Asserts that the returned documents have the expected titles (regardless of order).
1077      *
1078      * @param string[] $expected List of expected document titles
1079      * @param \core_search\document[] $results List of returned documents
1080      */
1081     protected function assert_result_titles(array $expected, array $results) {
1082         $titles = [];
1083         foreach ($results as $result) {
1084             $titles[] = $result->get('title');
1085         }
1086         sort($titles);
1087         sort($expected);
1088         $this->assertEquals($expected, $titles);
1089     }
1091     /**
1092      * Tests the get_supported_orders function for contexts where we can only use relevance
1093      * (system, category).
1094      */
1095     public function test_get_supported_orders_relevance_only() {
1096         global $DB;
1098         // System or category context: relevance only.
1099         $orders = $this->engine->get_supported_orders(\context_system::instance());
1100         $this->assertCount(1, $orders);
1101         $this->assertArrayHasKey('relevance', $orders);
1103         $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}');
1104         $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid));
1105         $this->assertCount(1, $orders);
1106         $this->assertArrayHasKey('relevance', $orders);
1107     }
1109     /**
1110      * Tests the get_supported_orders function for contexts where we support location as well
1111      * (course, activity, block).
1112      */
1113     public function test_get_supported_orders_relevance_and_location() {
1114         global $DB;
1116         // Test with course context.
1117         $generator = $this->getDataGenerator();
1118         $course = $generator->create_course(['fullname' => 'Frogs']);
1119         $coursecontext = \context_course::instance($course->id);
1121         $orders = $this->engine->get_supported_orders($coursecontext);
1122         $this->assertCount(2, $orders);
1123         $this->assertArrayHasKey('relevance', $orders);
1124         $this->assertArrayHasKey('location', $orders);
1125         $this->assertContains('Course: Frogs', $orders['location']);
1127         // Test with activity context.
1128         $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']);
1130         $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid));
1131         $this->assertCount(2, $orders);
1132         $this->assertArrayHasKey('relevance', $orders);
1133         $this->assertArrayHasKey('location', $orders);
1134         $this->assertContains('Page: Toads', $orders['location']);
1136         // Test with block context.
1137         $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id,
1138                 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*',
1139                 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1,
1140                 'configdata' => ''];
1141         $blockid = $DB->insert_record('block_instances', $instance);
1142         $blockcontext = \context_block::instance($blockid);
1144         $orders = $this->engine->get_supported_orders($blockcontext);
1145         $this->assertCount(2, $orders);
1146         $this->assertArrayHasKey('relevance', $orders);
1147         $this->assertArrayHasKey('location', $orders);
1148         $this->assertContains('Block: HTML', $orders['location']);
1149     }
1151     /**
1152      * Tests ordering by relevance vs location.
1153      */
1154     public function test_ordering() {
1155         // Create 2 courses and 2 activities.
1156         $generator = $this->getDataGenerator();
1157         $course1 = $generator->create_course(['fullname' => 'Course 1']);
1158         $course1context = \context_course::instance($course1->id);
1159         $course1page = $generator->create_module('page', ['course' => $course1]);
1160         $course1pagecontext = \context_module::instance($course1page->cmid);
1161         $course2 = $generator->create_course(['fullname' => 'Course 2']);
1162         $course2context = \context_course::instance($course2->id);
1163         $course2page = $generator->create_module('page', ['course' => $course2]);
1164         $course2pagecontext = \context_module::instance($course2page->cmid);
1166         // Create one search record in each activity and course.
1167         $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy');
1168         $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy');
1169         $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy');
1170         $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh');
1171         $this->search->index();
1173         // Default search works by relevance so the one with both words should be top.
1174         $querydata = new stdClass();
1175         $querydata->q = 'xyzzy plugh';
1176         $results = $this->search->search($querydata);
1177         $this->assertCount(4, $results);
1178         $this->assertEquals('C2P', $results[0]->get('title'));
1180         // Same if you explicitly specify relevance.
1181         $querydata->order = 'relevance';
1182         $results = $this->search->search($querydata);
1183         $this->assertEquals('C2P', $results[0]->get('title'));
1185         // If you specify order by location and you are in C2 or C2P then results are the same.
1186         $querydata->order = 'location';
1187         $querydata->context = $course2context;
1188         $results = $this->search->search($querydata);
1189         $this->assertEquals('C2P', $results[0]->get('title'));
1190         $querydata->context = $course2pagecontext;
1191         $results = $this->search->search($querydata);
1192         $this->assertEquals('C2P', $results[0]->get('title'));
1194         // But if you are in C1P then you get different results (C1P first).
1195         $querydata->context = $course1pagecontext;
1196         $results = $this->search->search($querydata);
1197         $this->assertEquals('C1P', $results[0]->get('title'));
1198     }
1200     /**
1201      * Tests with bogus content (that can be entered into Moodle) to see if it crashes.
1202      */
1203     public function test_bogus_content() {
1204         $generator = $this->getDataGenerator();
1205         $course1 = $generator->create_course(['fullname' => 'Course 1']);
1206         $course1context = \context_course::instance($course1->id);
1208         // It is possible to enter into a Moodle database content containing these characters,
1209         // which are Unicode non-characters / byte order marks. If sent to Solr, these cause
1210         // failures.
1211         $boguscontent = html_entity_decode('&#xfffe;') . 'frog';
1212         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1213         $boguscontent = html_entity_decode('&#xffff;') . 'frog';
1214         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1216         // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters
1217         // in total. Here are some of them - these work OK for me but it may depend on platform.
1218         $boguscontent = html_entity_decode('&#xfdd0;') . 'frog';
1219         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1220         $boguscontent = html_entity_decode('&#xfdef;') . 'frog';
1221         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1222         $boguscontent = html_entity_decode('&#x1fffe;') . 'frog';
1223         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1224         $boguscontent = html_entity_decode('&#x10ffff;') . 'frog';
1225         $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
1227         // Do the indexing (this will check it doesn't throw warnings).
1228         $this->search->index();
1230         // Confirm that all 6 documents are found in search.
1231         $querydata = new stdClass();
1232         $querydata->q = 'frog';
1233         $results = $this->search->search($querydata);
1234         $this->assertCount(6, $results);
1235     }
1237     /**
1238      * Adds a record to the mock search area, so that the search engine can find it later.
1239      *
1240      * @param int $courseid Course id
1241      * @param int $contextid Context id
1242      * @param string $title Title for search index
1243      * @param string $content Content for search index
1244      */
1245     protected function create_search_record($courseid, $contextid, $title, $content) {
1246         $record = new \stdClass();
1247         $record->content = $content;
1248         $record->title = $title;
1249         $record->courseid = $courseid;
1250         $record->contextid = $contextid;
1251         $this->generator->create_record($record);
1252     }