Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

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