Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [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      public function test_deleted_contexts_and_courses() {
 342          // Create some courses and activities.
 343          $generator = $this->getDataGenerator();
 344          $course1 = $generator->create_course(['fullname' => 'C1', 'summary' => 'xyzzy']);
 345          $course1page1 = $generator->create_module('page', ['course' => $course1, 'name' => 'C1P1', 'content' => 'xyzzy']);
 346          $generator->create_module('page', ['course' => $course1, 'name' => 'C1P2', 'content' => 'xyzzy']);
 347          $course2 = $generator->create_course(['fullname' => 'C2', 'summary' => 'xyzzy']);
 348          $course2page = $generator->create_module('page', ['course' => $course2, 'name' => 'C2P', 'content' => 'xyzzy']);
 349          $course2pagecontext = \context_module::instance($course2page->cmid);
 350  
 351          $this->search->index();
 352  
 353          // By default we have all data in the index.
 354          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']);
 355  
 356          // Say we delete the course2pagecontext...
 357          $this->engine->delete_index_for_context($course2pagecontext->id);
 358          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']);
 359  
 360          // Now delete the second course...
 361          $this->engine->delete_index_for_course($course2->id);
 362          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2']);
 363  
 364          // Finally let's delete using Moodle functions to check that works. Single context first.
 365          course_delete_module($course1page1->cmid);
 366          $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P2']);
 367          delete_course($course1, false);
 368          $this->assert_raw_index_contents('xyzzy', []);
 369      }
 370  
 371      /**
 372       * Check the contents of the index.
 373       *
 374       * @param string $searchword Word to match within the content field
 375       * @param string[] $expected Array of expected result titles, in alphabetical order
 376       */
 377      protected function assert_raw_index_contents(string $searchword, array $expected) {
 378          global $DB;
 379          $results = $DB->get_records_select('search_simpledb_index',
 380                  $DB->sql_like('content', '?'), ['%' . $searchword . '%'], "id, {$DB->sql_order_by_text('title')}");
 381          $titles = array_map(function($x) {
 382              return $x->title;
 383          }, $results);
 384          sort($titles);
 385          $this->assertEquals($expected, $titles);
 386      }
 387  
 388      /**
 389       * Adds a mock search area to the search system.
 390       */
 391      protected function add_mock_search_area() {
 392          $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area');
 393          $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area());
 394      }
 395  
 396      /**
 397       * Updates mssql fulltext index if necessary.
 398       *
 399       * @return bool
 400       */
 401      private function update_index() {
 402          global $DB;
 403  
 404          if (!$this->requires_manual_index_update()) {
 405              return;
 406          }
 407  
 408          $DB->execute("ALTER FULLTEXT INDEX ON {search_simpledb_index} START UPDATE POPULATION");
 409  
 410          $catalogname = $DB->get_prefix() . 'search_simpledb_catalog';
 411          $retries = 0;
 412          do {
 413              // 0.2 seconds.
 414              usleep(200000);
 415  
 416              $record = $DB->get_record_sql("SELECT FULLTEXTCATALOGPROPERTY(cat.name, 'PopulateStatus') AS [PopulateStatus]
 417                                               FROM sys.fulltext_catalogs AS cat
 418                                              WHERE cat.name = ?", array($catalogname));
 419              $retries++;
 420  
 421          } while ($retries < 100 && $record->populatestatus != '0');
 422  
 423          if ($retries === 100) {
 424              // No update after 20 seconds...
 425              $this->fail('Sorry, your SQL server fulltext search index is too slow.');
 426          }
 427      }
 428  
 429      /**
 430       * Mssql with fulltext support requires manual updates.
 431       *
 432       * @return bool
 433       */
 434      private function requires_manual_index_update() {
 435          global $DB;
 436          return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported());
 437      }
 438  }