Search moodle.org's
Developer Documentation

See Release Notes

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

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

   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   * Search base class to be extended by search areas.
  19   *
  20   * @package    core_search
  21   * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core_search;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /**
  30   * Base search implementation.
  31   *
  32   * Components and plugins interested in filling the search engine with data should extend this class (or any extension of this
  33   * class).
  34   *
  35   * @package    core_search
  36   * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  abstract class base {
  40  
  41      /**
  42       * The area name as defined in the class name.
  43       *
  44       * @var string
  45       */
  46      protected $areaname = null;
  47  
  48      /**
  49       * The component frankenstyle name.
  50       *
  51       * @var string
  52       */
  53      protected $componentname = null;
  54  
  55      /**
  56       * The component type (core or the plugin type).
  57       *
  58       * @var string
  59       */
  60      protected $componenttype = null;
  61  
  62      /**
  63       * The context levels the search implementation is working on.
  64       *
  65       * @var array
  66       */
  67      protected static $levels = [CONTEXT_SYSTEM];
  68  
  69      /**
  70       * An area id from the componentname and the area name.
  71       *
  72       * @var string
  73       */
  74      public $areaid;
  75  
  76      /**
  77       * Constructor.
  78       *
  79       * @throws \coding_exception
  80       * @return void
  81       */
  82      public final function __construct() {
  83  
  84          $classname = get_class($this);
  85  
  86          // Detect possible issues when defining the class.
  87          if (strpos($classname, '\search') === false) {
  88              throw new \coding_exception('Search area classes should be located in \PLUGINTYPE_PLUGINNAME\search\AREANAME.');
  89          } else if (strpos($classname, '_') === false) {
  90              throw new \coding_exception($classname . ' class namespace level 1 should be its component frankenstyle name');
  91          }
  92  
  93          $this->areaname = substr(strrchr($classname, '\\'), 1);
  94          $this->componentname = substr($classname, 0, strpos($classname, '\\'));
  95          $this->areaid = \core_search\manager::generate_areaid($this->componentname, $this->areaname);
  96          $this->componenttype = substr($this->componentname, 0, strpos($this->componentname, '_'));
  97      }
  98  
  99      /**
 100       * Returns context levels property.
 101       *
 102       * @return int
 103       */
 104      public static function get_levels() {
 105          return static::$levels;
 106      }
 107  
 108      /**
 109       * Returns the area id.
 110       *
 111       * @return string
 112       */
 113      public function get_area_id() {
 114          return $this->areaid;
 115      }
 116  
 117      /**
 118       * Returns the moodle component name.
 119       *
 120       * It might be the plugin name (whole frankenstyle name) or the core subsystem name.
 121       *
 122       * @return string
 123       */
 124      public function get_component_name() {
 125          return $this->componentname;
 126      }
 127  
 128      /**
 129       * Returns the component type.
 130       *
 131       * It might be a plugintype or 'core' for core subsystems.
 132       *
 133       * @return string
 134       */
 135      public function get_component_type() {
 136          return $this->componenttype;
 137      }
 138  
 139      /**
 140       * Returns the area visible name.
 141       *
 142       * @param bool $lazyload Usually false, unless when in admin settings.
 143       * @return string
 144       */
 145      public function get_visible_name($lazyload = false) {
 146  
 147          $component = $this->componentname;
 148  
 149          // Core subsystem strings go to lang/XX/search.php.
 150          if ($this->componenttype === 'core') {
 151              $component = 'search';
 152          }
 153          return get_string('search:' . $this->areaname, $component, null, $lazyload);
 154      }
 155  
 156      /**
 157       * Returns the config var name.
 158       *
 159       * It depends on whether it is a moodle subsystem or a plugin as plugin-related config should remain in their own scope.
 160       *
 161       * @access private
 162       * @return string Config var path including the plugin (or component) and the varname
 163       */
 164      public function get_config_var_name() {
 165  
 166          if ($this->componenttype === 'core') {
 167              // Core subsystems config in core_search and setting name using only [a-zA-Z0-9_]+.
 168              $parts = \core_search\manager::extract_areaid_parts($this->areaid);
 169              return array('core_search', $parts[0] . '_' . $parts[1]);
 170          }
 171  
 172          // Plugins config in the plugin scope.
 173          return array($this->componentname, 'search_' . $this->areaname);
 174      }
 175  
 176      /**
 177       * Returns all the search area configuration.
 178       *
 179       * @return array
 180       */
 181      public function get_config() {
 182          list($componentname, $varname) = $this->get_config_var_name();
 183  
 184          $config = [];
 185          $settingnames = self::get_settingnames();
 186          foreach ($settingnames as $name) {
 187              $config[$varname . $name] = get_config($componentname, $varname . $name);
 188          }
 189  
 190          // Search areas are enabled by default.
 191          if ($config[$varname . '_enabled'] === false) {
 192              $config[$varname . '_enabled'] = 1;
 193          }
 194          return $config;
 195      }
 196  
 197      /**
 198       * Return a list of all required setting names.
 199       *
 200       * @return array
 201       */
 202      public static function get_settingnames() {
 203          return array('_enabled', '_indexingstart', '_indexingend', '_lastindexrun',
 204              '_docsignored', '_docsprocessed', '_recordsprocessed', '_partial');
 205      }
 206  
 207      /**
 208       * Is the search component enabled by the system administrator?
 209       *
 210       * @return bool
 211       */
 212      public function is_enabled() {
 213          list($componentname, $varname) = $this->get_config_var_name();
 214  
 215          $value = get_config($componentname, $varname . '_enabled');
 216  
 217          // Search areas are enabled by default.
 218          if ($value === false) {
 219              $value = 1;
 220          }
 221          return (bool)$value;
 222      }
 223  
 224      public function set_enabled($isenabled) {
 225          list($componentname, $varname) = $this->get_config_var_name();
 226          return set_config($varname . '_enabled', $isenabled, $componentname);
 227      }
 228  
 229      /**
 230       * Gets the length of time spent indexing this area (the last time it was indexed).
 231       *
 232       * @return int|bool Time in seconds spent indexing this area last time, false if never indexed
 233       */
 234      public function get_last_indexing_duration() {
 235          list($componentname, $varname) = $this->get_config_var_name();
 236          $start = get_config($componentname, $varname . '_indexingstart');
 237          $end = get_config($componentname, $varname . '_indexingend');
 238          if ($start && $end) {
 239              return $end - $start;
 240          } else {
 241              return false;
 242          }
 243      }
 244  
 245      /**
 246       * Returns true if this area uses file indexing.
 247       *
 248       * @return bool
 249       */
 250      public function uses_file_indexing() {
 251          return false;
 252      }
 253  
 254      /**
 255       * Returns a recordset ordered by modification date ASC.
 256       *
 257       * Each record can include any data self::get_document might need but it must:
 258       * - Include an 'id' field: Unique identifier (in this area's scope) of a document to index in the search engine
 259       *   If the indexed content field can contain embedded files, the 'id' value should match the filearea itemid.
 260       * - Only return data modified since $modifiedfrom, including $modifiedform to prevent
 261       *   some records from not being indexed (e.g. your-timemodified-fieldname >= $modifiedfrom)
 262       * - Order the returned data by time modified in ascending order, as \core_search::manager will need to store the modified time
 263       *   of the last indexed document.
 264       *
 265       * Since Moodle 3.4, subclasses should instead implement get_document_recordset, which has
 266       * an additional context parameter. This function continues to work for implementations which
 267       * haven't been updated, or where the context parameter is not required.
 268       *
 269       * @param int $modifiedfrom
 270       * @return \moodle_recordset
 271       */
 272      public function get_recordset_by_timestamp($modifiedfrom = 0) {
 273          $result = $this->get_document_recordset($modifiedfrom);
 274          if ($result === false) {
 275              throw new \coding_exception(
 276                      'Search area must implement get_document_recordset or get_recordset_by_timestamp');
 277          }
 278          return $result;
 279      }
 280  
 281      /**
 282       * Returns a recordset containing all items from this area, optionally within the given context,
 283       * and including only items modifed from (>=) the specified time. The recordset must be ordered
 284       * in ascending order of modified time.
 285       *
 286       * Each record can include any data self::get_document might need. It must include an 'id'
 287       * field,a unique identifier (in this area's scope) of a document to index in the search engine.
 288       * If the indexed content field can contain embedded files, the 'id' value should match the
 289       * filearea itemid.
 290       *
 291       * The return value can be a recordset, null (if this area does not provide any results in the
 292       * given context and there is no need to do a database query to find out), or false (if this
 293       * facility is not currently supported by this search area).
 294       *
 295       * If this function returns false, then:
 296       * - If indexing the entire system (no context restriction) the search indexer will try
 297       *   get_recordset_by_timestamp instead
 298       * - If trying to index a context (e.g. when restoring a course), the search indexer will not
 299       *   index this area, so that restored content may not be indexed.
 300       *
 301       * The default implementation returns false, indicating that this facility is not supported and
 302       * the older get_recordset_by_timestamp function should be used.
 303       *
 304       * This function must accept all possible values for the $context parameter. For example, if
 305       * you are implementing this function for the forum module, it should still operate correctly
 306       * if called with the context for a glossary module, or for the HTML block. (In these cases
 307       * where it will not return any data, it may return null.)
 308       *
 309       * The $context parameter can also be null or the system context; both of these indicate that
 310       * all data, without context restriction, should be returned.
 311       *
 312       * @param int $modifiedfrom Return only records modified after this date
 313       * @param \context|null $context Context (null means no context restriction)
 314       * @return \moodle_recordset|null|false Recordset / null if no results / false if not supported
 315       * @since Moodle 3.4
 316       */
 317      public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
 318          return false;
 319      }
 320  
 321      /**
 322       * Checks if get_document_recordset is supported for this search area.
 323       *
 324       * For many uses you can simply call get_document_recordset and see if it returns false, but
 325       * this function is useful when you don't want to actually call the function right away.
 326       */
 327      public function supports_get_document_recordset() {
 328          // Easiest way to check this is simply to see if the class has overridden the default
 329          // function.
 330          $method = new \ReflectionMethod($this, 'get_document_recordset');
 331          return $method->getDeclaringClass()->getName() !== self::class;
 332      }
 333  
 334      /**
 335       * Returns the document related with the provided record.
 336       *
 337       * This method receives a record with the document id and other info returned by get_recordset_by_timestamp
 338       * or get_recordset_by_contexts that might be useful here. The idea is to restrict database queries to
 339       * minimum as this function will be called for each document to index. As an alternative, use cached data.
 340       *
 341       * Internally it should use \core_search\document to standarise the documents before sending them to the search engine.
 342       *
 343       * Search areas should send plain text to the search engine, use the following function to convert any user
 344       * input data to plain text: {@link content_to_text}
 345       *
 346       * Valid keys for the options array are:
 347       *     indexfiles => File indexing is enabled if true.
 348       *     lastindexedtime => The last time this area was indexed. 0 if never indexed.
 349       *
 350       * The lastindexedtime value is not set if indexing a specific context rather than the whole
 351       * system.
 352       *
 353       * @param \stdClass $record A record containing, at least, the indexed document id and a modified timestamp
 354       * @param array     $options Options for document creation
 355       * @return \core_search\document
 356       */
 357      abstract public function get_document($record, $options = array());
 358  
 359      /**
 360       * Returns the document title to display.
 361       *
 362       * Allow to customize the document title string to display.
 363       *
 364       * @param \core_search\document $doc
 365       * @return string Document title to display in the search results page
 366       */
 367      public function get_document_display_title(\core_search\document $doc) {
 368  
 369          return $doc->get('title');
 370      }
 371  
 372      /**
 373       * Return the context info required to index files for
 374       * this search area.
 375       *
 376       * Should be onerridden by each search area.
 377       *
 378       * @return array
 379       */
 380      public function get_search_fileareas() {
 381          $fileareas = array();
 382  
 383          return $fileareas;
 384      }
 385  
 386      /**
 387       * Files related to the current document are attached,
 388       * to the document object ready for indexing by
 389       * Global Search.
 390       *
 391       * The default implementation retrieves all files for
 392       * the file areas returned by get_search_fileareas().
 393       * If you need to filter files to specific items per
 394       * file area, you will need to override this method
 395       * and explicitly provide the items.
 396       *
 397       * @param document $document The current document
 398       * @return void
 399       */
 400      public function attach_files($document) {
 401          $fileareas = $this->get_search_fileareas();
 402          $contextid = $document->get('contextid');
 403          $component = $this->get_component_name();
 404          $itemid = $document->get('itemid');
 405  
 406          foreach ($fileareas as $filearea) {
 407              $fs = get_file_storage();
 408              $files = $fs->get_area_files($contextid, $component, $filearea, $itemid, '', false);
 409  
 410              foreach ($files as $file) {
 411                  $document->add_stored_file($file);
 412              }
 413          }
 414  
 415      }
 416  
 417      /**
 418       * Can the current user see the document.
 419       *
 420       * @param int $id The internal search area entity id.
 421       * @return int manager:ACCESS_xx constant
 422       */
 423      abstract public function check_access($id);
 424  
 425      /**
 426       * Returns a url to the document, it might match self::get_context_url().
 427       *
 428       * @param \core_search\document $doc
 429       * @return \moodle_url
 430       */
 431      abstract public function get_doc_url(\core_search\document $doc);
 432  
 433      /**
 434       * Returns a url to the document context.
 435       *
 436       * @param \core_search\document $doc
 437       * @return \moodle_url
 438       */
 439      abstract public function get_context_url(\core_search\document $doc);
 440  
 441      /**
 442       * Helper function that gets SQL useful for restricting a search query given a passed-in
 443       * context, for data stored at course level.
 444       *
 445       * The SQL returned will be zero or more JOIN statements, surrounded by whitespace, which act
 446       * as restrictions on the query based on the rows in a module table.
 447       *
 448       * You can pass in a null or system context, which will both return an empty string and no
 449       * params.
 450       *
 451       * Returns an array with two nulls if there can be no results for a course within this context.
 452       *
 453       * If named parameters are used, these will be named gclcrs0, gclcrs1, etc. The table aliases
 454       * used in SQL also all begin with gclcrs, to avoid conflicts.
 455       *
 456       * @param \context|null $context Context to restrict the query
 457       * @param string $coursetable Name of alias for course table e.g. 'c'
 458       * @param int $paramtype Type of SQL parameters to use (default question mark)
 459       * @return array Array with SQL and parameters; both null if no need to query
 460       * @throws \coding_exception If called with invalid params
 461       */
 462      protected function get_course_level_context_restriction_sql(?\context $context,
 463              $coursetable, $paramtype = SQL_PARAMS_QM) {
 464          global $DB;
 465  
 466          if (!$context) {
 467              return ['', []];
 468          }
 469  
 470          switch ($paramtype) {
 471              case SQL_PARAMS_QM:
 472                  $param1 = '?';
 473                  $param2 = '?';
 474                  $key1 = 0;
 475                  $key2 = 1;
 476                  break;
 477              case SQL_PARAMS_NAMED:
 478                  $param1 = ':gclcrs0';
 479                  $param2 = ':gclcrs1';
 480                  $key1 = 'gclcrs0';
 481                  $key2 = 'gclcrs1';
 482                  break;
 483              default:
 484                  throw new \coding_exception('Unexpected $paramtype: ' . $paramtype);
 485          }
 486  
 487          $params = [];
 488          switch ($context->contextlevel) {
 489              case CONTEXT_SYSTEM:
 490                  $sql = '';
 491                  break;
 492  
 493              case CONTEXT_COURSECAT:
 494                  // Find all courses within the specified category or any sub-category.
 495                  $pathmatch = $DB->sql_like('gclcrscc2.path',
 496                          $DB->sql_concat('gclcrscc1.path', $param2));
 497                  $sql = " JOIN {course_categories} gclcrscc1 ON gclcrscc1.id = $param1
 498                           JOIN {course_categories} gclcrscc2 ON gclcrscc2.id = $coursetable.category
 499                                AND (gclcrscc2.id = gclcrscc1.id OR $pathmatch) ";
 500                  $params[$key1] = $context->instanceid;
 501                  // Note: This param is a bit annoying as it obviously never changes, but sql_like
 502                  // throws a debug warning if you pass it anything with quotes in, so it has to be
 503                  // a bound parameter.
 504                  $params[$key2] = '/%';
 505                  break;
 506  
 507              case CONTEXT_COURSE:
 508                  // We just join again against the same course entry and confirm that it has the
 509                  // same id as the context.
 510                  $sql = " JOIN {course} gclcrsc ON gclcrsc.id = $coursetable.id
 511                                AND gclcrsc.id = $param1";
 512                  $params[$key1] = $context->instanceid;
 513                  break;
 514  
 515              case CONTEXT_BLOCK:
 516              case CONTEXT_MODULE:
 517              case CONTEXT_USER:
 518                  // Context cannot contain any courses.
 519                  return [null, null];
 520  
 521              default:
 522                  throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel);
 523          }
 524  
 525          return [$sql, $params];
 526      }
 527  
 528      /**
 529       * Gets a list of all contexts to reindex when reindexing this search area. The list should be
 530       * returned in an order that is likely to be suitable when reindexing, for example with newer
 531       * contexts first.
 532       *
 533       * The default implementation simply returns the system context, which will result in
 534       * reindexing everything in normal date order (oldest first).
 535       *
 536       * @return \Iterator Iterator of contexts to reindex
 537       */
 538      public function get_contexts_to_reindex() {
 539          return new \ArrayIterator([\context_system::instance()]);
 540      }
 541  
 542      /**
 543       * Returns an icon instance for the document.
 544       *
 545       * @param \core_search\document $doc
 546       * @return \core_search\document_icon
 547       */
 548      public function get_doc_icon(document $doc) : document_icon {
 549          return new document_icon('i/empty');
 550      }
 551  
 552      /**
 553       * Returns a list of category names associated with the area.
 554       *
 555       * @return array
 556       */
 557      public function get_category_names() {
 558          return [manager::SEARCH_AREA_CATEGORY_OTHER];
 559      }
 560  }