Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 39 and 401] [Versions 401 and 402] [Versions 401 and 403]

   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  namespace search_simpledb;
  18  
  19  defined('MOODLE_INTERNAL') || die();
  20  
  21  global $CFG;
  22  require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
  23  require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php');
  24  
  25  /**
  26   * Simple search engine base unit tests.
  27   *
  28   * @package     search_simpledb
  29   * @category    test
  30   * @copyright   2016 David Monllao {@link http://www.davidmonllao.com}
  31   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  32   */
  33  class engine_test extends \advanced_testcase {
  34  
  35      /**
  36       * @var \core_search::manager
  37       */
  38      protected $search = null;
  39  
  40      /**
  41       * @var \
  42       */
  43      protected $engine = null;
  44  
  45      /**
  46       * @var core_search_generator
  47       */
  48      protected $generator = null;
  49  
  50      /**
  51       * Initial stuff.
  52       *
  53       * @return void
  54       */
  55      public function setUp(): void {
  56          $this->resetAfterTest();
  57  
  58          if ($this->requires_manual_index_update()) {
  59              // We need to update fulltext index manually, which requires an alter table statement.
  60              $this->preventResetByRollback();
  61          }
  62  
  63          set_config('enableglobalsearch', true);
  64  
  65          // Inject search_simpledb engine into the testable core search as we need to add the mock
  66          // search component to it.
  67  
  68          $this->engine = new \search_simpledb\engine();
  69          $this->search = \testable_core_search::instance($this->engine);
  70  
  71          $this->generator = self::getDataGenerator()->get_plugin_generator('core_search');
  72          $this->generator->setup();
  73  
  74          $this->setAdminUser();
  75      }
  76  
  77      /**
  78       * tearDown
  79       *
  80       * @return void
  81       */
  82      public function tearDown(): void {
  83          // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup.
  84          if ($this->generator) {
  85              // Moodle DML freaks out if we don't teardown the temp table after each run.
  86              $this->generator->teardown();
  87              $this->generator = null;
  88          }
  89      }
  90  
  91      /**
  92       * Test indexing process.
  93       *
  94       * @return void
  95       */
  96      public function test_index() {
  97          global $DB;
  98  
  99          $this->add_mock_search_area();
 100  
 101          $record = new \stdClass();
 102          $record->timemodified = time() - 1;
 103          $this->generator->create_record($record);
 104  
 105          // Data gets into the search engine.
 106          $this->assertTrue($this->search->index());
 107  
 108          // Not anymore as everything was already added.
 109          sleep(1);
 110          $this->assertFalse($this->search->index());
 111  
 112          $this->generator->create_record();
 113  
 114          // Indexing again once there is new data.
 115          $this->assertTrue($this->search->index());
 116      }
 117  
 118      /**
 119       * Test search filters.
 120       *
 121       * @return void
 122       */
 123      public function test_search() {
 124          global $USER, $DB;
 125  
 126          $this->add_mock_search_area();
 127  
 128          $this->generator->create_record();
 129          $record = new \stdClass();
 130          $record->title = "Special title";
 131          $this->generator->create_record($record);
 132  
 133          $this->search->index();
 134          $this->update_index();
 135  
 136          $querydata = new \stdClass();
 137          $querydata->q = 'message';
 138          $results = $this->search->search($querydata);
 139          $this->assertCount(2, $results);
 140  
 141          // Based on core_mocksearch\search\indexer.
 142          $this->assertEquals($USER->id, $results[0]->get('userid'));
 143          $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid'));
 144  
 145          // Do a test to make sure we aren't searching non-query fields, like areaid.
 146          $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 147          $this->assertCount(0, $this->search->search($querydata));
 148          $querydata->q = 'message';
 149  
 150          sleep(1);
 151          $beforeadding = time();
 152          sleep(1);
 153          $this->generator->create_record();
 154          $this->search->index();
 155          $this->update_index();
 156  
 157          // Timestart.
 158          $querydata->timestart = $beforeadding;
 159          $this->assertCount(1, $this->search->search($querydata));
 160  
 161          // Timeend.
 162          unset($querydata->timestart);
 163          $querydata->timeend = $beforeadding;
 164          $this->assertCount(2, $this->search->search($querydata));
 165  
 166          // Title.
 167          unset($querydata->timeend);
 168          $querydata->title = 'Special title';
 169          $this->assertCount(1, $this->search->search($querydata));
 170  
 171          // Course IDs.
 172          unset($querydata->title);
 173          $querydata->courseids = array(SITEID + 1);
 174          $this->assertCount(0, $this->search->search($querydata));
 175  
 176          $querydata->courseids = array(SITEID);
 177          $this->assertCount(3, $this->search->search($querydata));
 178  
 179          // Now try some area-id combinations.
 180          unset($querydata->courseids);
 181          $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post');
 182          $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 183  
 184          $querydata->areaids = array($forumpostareaid);
 185          $this->assertCount(0, $this->search->search($querydata));
 186  
 187          $querydata->areaids = array($forumpostareaid, $mockareaid);
 188          $this->assertCount(3, $this->search->search($querydata));
 189  
 190          $querydata->areaids = array($mockareaid);
 191          $this->assertCount(3, $this->search->search($querydata));
 192  
 193          $querydata->areaids = array();
 194          $this->assertCount(3, $this->search->search($querydata));
 195  
 196          // Check that index contents get updated.
 197          $this->generator->delete_all();
 198          $this->search->index(true);
 199          $this->update_index();
 200          unset($querydata->title);
 201          $querydata->q = '';
 202          $this->assertCount(0, $this->search->search($querydata));
 203      }
 204  
 205      /**
 206       * Test delete function
 207       *
 208       * @return void
 209       */
 210      public function test_delete() {
 211  
 212          $this->add_mock_search_area();
 213  
 214          $this->generator->create_record();
 215          $this->generator->create_record();
 216          $this->search->index();
 217          $this->update_index();
 218  
 219          $querydata = new \stdClass();
 220          $querydata->q = 'message';
 221  
 222          $this->assertCount(2, $this->search->search($querydata));
 223  
 224          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 225          $this->search->delete_index($areaid);
 226          $this->update_index();
 227          $this->assertCount(0, $this->search->search($querydata));
 228      }
 229  
 230      /**
 231       * Test user is allowed.
 232       *
 233       * @return void
 234       */
 235      public function test_alloweduserid() {
 236  
 237          $this->add_mock_search_area();
 238  
 239          $area = new \core_mocksearch\search\mock_search_area();
 240  
 241          $record = $this->generator->create_record();
 242  
 243          // Get the doc and insert the default doc.
 244          $doc = $area->get_document($record);
 245          $this->engine->add_document($doc);
 246  
 247          $users = array();
 248          $users[] = $this->getDataGenerator()->create_user();
 249          $users[] = $this->getDataGenerator()->create_user();
 250          $users[] = $this->getDataGenerator()->create_user();
 251  
 252          // Add a record that only user 100 can see.
 253          $originalid = $doc->get('id');
 254  
 255          // Now add a custom doc for each user.
 256          foreach ($users as $user) {
 257              $doc = $area->get_document($record);
 258              $doc->set('id', $originalid.'-'.$user->id);
 259              $doc->set('owneruserid', $user->id);
 260              $this->engine->add_document($doc);
 261          }
 262          $this->update_index();
 263  
 264          $this->engine->area_index_complete($area->get_area_id());
 265  
 266          $querydata = new \stdClass();
 267          $querydata->q = 'message';
 268          $querydata->title = $doc->get('title');
 269  
 270          // We are going to go through each user and see if they get the original and the owned doc.
 271          foreach ($users as $user) {
 272              $this->setUser($user);
 273  
 274              $results = $this->search->search($querydata);
 275              $this->assertCount(2, $results);
 276  
 277              $owned = 0;
 278              $notowned = 0;
 279  
 280              // We don't know what order we will get the results in, so we are doing this.
 281              foreach ($results as $result) {
 282                  $owneruserid = $result->get('owneruserid');
 283                  if (empty($owneruserid)) {
 284                      $notowned++;
 285                      $this->assertEquals(0, $owneruserid);
 286                      $this->assertEquals($originalid, $result->get('id'));
 287                  } else {
 288                      $owned++;
 289                      $this->assertEquals($user->id, $owneruserid);
 290                      $this->assertEquals($originalid.'-'.$user->id, $result->get('id'));
 291                  }
 292              }
 293  
 294              $this->assertEquals(1, $owned);
 295              $this->assertEquals(1, $notowned);
 296          }
 297  
 298          // Now test a user with no owned results.
 299          $otheruser = $this->getDataGenerator()->create_user();
 300          $this->setUser($otheruser);
 301  
 302          $results = $this->search->search($querydata);
 303          $this->assertCount(1, $results);
 304  
 305          $this->assertEquals(0, $results[0]->get('owneruserid'));
 306          $this->assertEquals($originalid, $results[0]->get('id'));
 307      }
 308  
 309      public function test_delete_by_id() {
 310  
 311          $this->add_mock_search_area();
 312  
 313          $this->generator->create_record();
 314          $this->generator->create_record();
 315          $this->search->index();
 316          $this->update_index();
 317  
 318          $querydata = new \stdClass();
 319  
 320          // Then search to make sure they are there.
 321          $querydata->q = 'message';
 322          $results = $this->search->search($querydata);
 323          $this->assertCount(2, $results);
 324  
 325          $first = reset($results);
 326          $deleteid = $first->get('id');
 327  
 328          $this->engine->delete_by_id($deleteid);
 329          $this->update_index();
 330  
 331          // Check that we don't get a result for it anymore.
 332          $results = $this->search->search($querydata);
 333          $this->assertCount(1, $results);
 334          $result = reset($results);
 335          $this->assertNotEquals($deleteid, $result->get('id'));
 336      }
 337  
 338      /**
 339       * Tries out deleting data for a context or a course.
 340       *
 341       * @throws moodle_exception
 342       */
 343      public function test_deleted_contexts_and_courses() {
 344          // Create some courses and activities.
 345          $generator = $this->getDataGenerator();
 346          $course1 = $generator->create_course(['fullname' => 'C1', 'summary' => 'xyzzy']);
 347          $course1page1 = $generator->create_module('page', ['course' => $course1, 'name' => 'C1P1', 'content' => 'xyzzy']);
 348          $generator->create_module('page', ['course' => $course1, 'name' => 'C1P2', 'content' => 'xyzzy']);
 349          $course2 = $generator->create_course(['fullname' => 'C2', 'summary' => 'xyzzy']);
 350          $course2page = $generator->create_module('page', ['course' => $course2, 'name' => 'C2P', 'content' => 'xyzzy']);
 351          $course2pagecontext = \context_module::instance($course2page->cmid);
 352  
 353          $this->search->index();
 354  
 355          // By default we have all data in the index.
 356          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']);
 357  
 358          // Say we delete the course2pagecontext...
 359          $this->engine->delete_index_for_context($course2pagecontext->id);
 360          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']);
 361  
 362          // Now delete the second course...
 363          $this->engine->delete_index_for_course($course2->id);
 364          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2']);
 365  
 366          // Finally let's delete using Moodle functions to check that works. Single context first.
 367          course_delete_module($course1page1->cmid);
 368          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P2']);
 369          delete_course($course1, false);
 370          $this->assert_raw_index_contents('xyzzy', []);
 371      }
 372  
 373      /**
 374       * Check the contents of the index.
 375       *
 376       * @param string $searchword Word to match within the content field
 377       * @param string[] $expected Array of expected result titles, in alphabetical order
 378       * @throws dml_exception
 379       */
 380      protected function assert_raw_index_contents(string $searchword, array $expected) {
 381          global $DB;
 382          $results = $DB->get_records_select('search_simpledb_index',
 383                  $DB->sql_like('content', '?'), ['%' . $searchword . '%'], "id, {$DB->sql_order_by_text('title')}");
 384          $titles = array_map(function($x) {
 385              return $x->title;
 386          }, $results);
 387          sort($titles);
 388          $this->assertEquals($expected, $titles);
 389      }
 390  
 391      /**
 392       * Adds a mock search area to the search system.
 393       */
 394      protected function add_mock_search_area() {
 395          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 396          $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area());
 397      }
 398  
 399      /**
 400       * Updates mssql fulltext index if necessary.
 401       *
 402       * @return bool
 403       */
 404      private function update_index() {
 405          global $DB;
 406  
 407          if (!$this->requires_manual_index_update()) {
 408              return;
 409          }
 410  
 411          $DB->execute("ALTER FULLTEXT INDEX ON {search_simpledb_index} START UPDATE POPULATION");
 412  
 413          $catalogname = $DB->get_prefix() . 'search_simpledb_catalog';
 414          $retries = 0;
 415          do {
 416              // 0.2 seconds.
 417              usleep(200000);
 418  
 419              $record = $DB->get_record_sql("SELECT FULLTEXTCATALOGPROPERTY(cat.name, 'PopulateStatus') AS [PopulateStatus]
 420                                               FROM sys.fulltext_catalogs AS cat
 421                                              WHERE cat.name = ?", array($catalogname));
 422              $retries++;
 423  
 424          } while ($retries < 100 && $record->populatestatus != '0');
 425  
 426          if ($retries === 100) {
 427              // No update after 20 seconds...
 428              $this->fail('Sorry, your SQL server fulltext search index is too slow.');
 429          }
 430      }
 431  
 432      /**
 433       * Mssql with fulltext support requires manual updates.
 434       *
 435       * @return bool
 436       */
 437      private function requires_manual_index_update() {
 438          global $DB;
 439          return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported());
 440      }
 441  }