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.
   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   * Class core_tag_index_builder
  19   *
  20   * @package   core_tag
  21   * @copyright 2016 Marina Glancy
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  /**
  28   * Helper to build tag index
  29   *
  30   * This can be used by components to implement tag area callbacks. This is especially
  31   * useful for in-course content when we need to check and cache user's access to
  32   * multiple courses. Course access and accessible items are stored in session cache
  33   * with 15 minutes expiry time.
  34   *
  35   * Example of usage:
  36   *
  37   * $builder = new core_tag_index_builder($component, $itemtype, $sql, $params, $from, $limit);
  38   * while ($item = $builder->has_item_that_needs_access_check()) {
  39   *     if (!$builder->can_access_course($item->courseid)) {
  40   *         $builder->set_accessible($item, false);
  41   *     } else {
  42   *         $accessible = true; // Check access and set $accessible respectively.
  43   *         $builder->set_accessible($item, $accessible);
  44   *     }
  45   * }
  46   * $items = $builder->get_items();
  47   *
  48   * @package   core_tag
  49   * @copyright 2016 Marina Glancy
  50   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  51   */
  52  class core_tag_index_builder {
  53  
  54      /** @var string component specified in the constructor */
  55      protected $component;
  56  
  57      /** @var string itemtype specified in the constructor */
  58      protected $itemtype;
  59  
  60      /** @var string SQL statement */
  61      protected $sql;
  62  
  63      /** @var array parameters for SQL statement */
  64      protected $params;
  65  
  66      /** @var int index from which to return records */
  67      protected $from;
  68  
  69      /** @var int maximum number of records to return */
  70      protected $limit;
  71  
  72      /** @var array result of SQL query */
  73      protected $items;
  74  
  75      /** @var array list of item ids ( array_keys($this->items) ) */
  76      protected $itemkeys;
  77  
  78      /** @var string alias of the item id in the SQL result */
  79      protected $idfield = 'id';
  80  
  81      /** @var array cache of items accessibility (id => bool) */
  82      protected $accessibleitems;
  83  
  84      /** @var array cache of courses accessibility (courseid => bool) */
  85      protected $courseaccess;
  86  
  87      /** @var bool indicates that items cache was changed in this class and needs pushing to MUC */
  88      protected $cachechangedaccessible = false;
  89  
  90      /** @var bool indicates that course accessibiity cache was changed in this class and needs pushing to MUC */
  91      protected $cachechangedcourse = false;
  92  
  93      /** @var array cached courses (not pushed to MUC) */
  94      protected $courses;
  95  
  96      /**
  97       * Constructor.
  98       *
  99       * Specify the SQL query for retrieving the tagged items, SQL query must:
 100       * - return the item id as the first field and make sure that it is unique in the result
 101       * - provide ORDER BY that exclude any possibility of random results, if $fromctx was specified when searching
 102       *   for tagged items it is the best practice to make sure that items from this context are returned first.
 103       *
 104       * This query may also contain placeholders %COURSEFILTER% or %ITEMFILTER% that will be substituted with
 105       * expressions excluding courses and/or filters that are already known as inaccessible.
 106       *
 107       * Example: "WHERE c.id %COURSEFILTER% AND cm.id %ITEMFILTER%"
 108       *
 109       * This query may contain fields to preload context if context is needed for formatting values.
 110       *
 111       * It is recommended to sort by course sortorder first, this way the items from the same course will be next to
 112       * each other and the sequence of courses will the same in different tag areas.
 113       *
 114       * @param string $component component responsible for tagging
 115       * @param string $itemtype type of item that is being tagged
 116       * @param string $sql SQL query that would retrieve all relevant items without permission check
 117       * @param array $params parameters for the query (must be named)
 118       * @param int $from return a subset of records, starting at this point
 119       * @param int $limit return a subset comprising this many records in total (this field is NOT optional)
 120       */
 121      public function __construct($component, $itemtype, $sql, $params, $from, $limit) {
 122          $this->component = preg_replace('/[^A-Za-z0-9_]/i', '', $component);
 123          $this->itemtype = preg_replace('/[^A-Za-z0-9_]/i', '', $itemtype);
 124          $this->sql = $sql;
 125          $this->params = $params;
 126          $this->from = $from;
 127          $this->limit = $limit;
 128          $this->courses = array();
 129      }
 130  
 131      /**
 132       * Substitute %COURSEFILTER% with an expression filtering out courses where current user does not have access
 133       */
 134      protected function prepare_sql_courses() {
 135          global $DB;
 136          if (!preg_match('/\\%COURSEFILTER\\%/', $this->sql)) {
 137              return;
 138          }
 139          $this->init_course_access();
 140          $unaccessiblecourses = array_filter($this->courseaccess, function($item) {
 141              return !$item;
 142          });
 143          $idx = 0;
 144          while (preg_match('/^([^\\0]*?)\\%COURSEFILTER\\%([^\\0]*)$/', $this->sql, $matches)) {
 145              list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessiblecourses),
 146                      SQL_PARAMS_NAMED, 'ca_'.($idx++).'_', false, 0);
 147              $this->sql = $matches[1].' '.$sql.' '.$matches[2];
 148              $this->params += $params;
 149          }
 150      }
 151  
 152      /**
 153       * Substitute %ITEMFILTER% with an expression filtering out items where current user does not have access
 154       */
 155      protected function prepare_sql_items() {
 156          global $DB;
 157          if (!preg_match('/\\%ITEMFILTER\\%/', $this->sql)) {
 158              return;
 159          }
 160          $this->init_items_access();
 161          $unaccessibleitems = array_filter($this->accessibleitems, function($item) {
 162              return !$item;
 163          });
 164          $idx = 0;
 165          while (preg_match('/^([^\\0]*?)\\%ITEMFILTER\\%([^\\0]*)$/', $this->sql, $matches)) {
 166              list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessibleitems),
 167                      SQL_PARAMS_NAMED, 'ia_'.($idx++).'_', false, 0);
 168              $this->sql = $matches[1].' '.$sql.' '.$matches[2];
 169              $this->params += $params;
 170          }
 171      }
 172  
 173      /**
 174       * Ensures that SQL query was executed and $this->items is filled
 175       */
 176      protected function retrieve_items() {
 177          global $DB;
 178          if ($this->items !== null) {
 179              return;
 180          }
 181          $this->prepare_sql_courses();
 182          $this->prepare_sql_items();
 183          $this->items = $DB->get_records_sql($this->sql, $this->params);
 184          $this->itemkeys = array_keys($this->items);
 185          if ($this->items) {
 186              // Find the name of the first key of the item - usually 'id' but can be something different.
 187              // This must be a unique identifier of the item.
 188              $firstitem = reset($this->items);
 189              $firstitemarray = (array)$firstitem;
 190              $this->idfield = key($firstitemarray);
 191          }
 192      }
 193  
 194      /**
 195       * Returns the filtered records from SQL query result.
 196       *
 197       * This function can only be executed after $builder->has_item_that_needs_access_check() returns null
 198       *
 199       *
 200       * @return array
 201       */
 202      public function get_items() {
 203          global $DB, $CFG;
 204          if (is_siteadmin()) {
 205              $this->sql = preg_replace('/\\%COURSEFILTER\\%/', '<>0', $this->sql);
 206              $this->sql = preg_replace('/\\%ITEMFILTER\\%/', '<>0', $this->sql);
 207              return $DB->get_records_sql($this->sql, $this->params, $this->from, $this->limit);
 208          }
 209          if ($CFG->debugdeveloper && $this->has_item_that_needs_access_check()) {
 210              debugging('Caller must ensure that has_item_that_needs_access_check() does not return anything '
 211                      . 'before calling get_items(). The item list may be incomplete', DEBUG_DEVELOPER);
 212          }
 213          $this->retrieve_items();
 214          $this->save_caches();
 215          $idx = 0;
 216          $items = array();
 217          foreach ($this->itemkeys as $id) {
 218              if (!array_key_exists($id, $this->accessibleitems) || !$this->accessibleitems[$id]) {
 219                  continue;
 220              }
 221              if ($idx >= $this->from) {
 222                  $items[$id] = $this->items[$id];
 223              }
 224              $idx++;
 225              if ($idx >= $this->from + $this->limit) {
 226                  break;
 227              }
 228          }
 229          return $items;
 230      }
 231  
 232      /**
 233       * Returns the first row from the SQL result that we don't know whether it is accessible by user or not.
 234       *
 235       * This will return null when we have necessary number of accessible items to return in {@link get_items()}
 236       *
 237       * After analyzing you may decide to mark not only this record but all similar as accessible or not accessible.
 238       * For example, if you already call get_fast_modinfo() to check this item's accessibility, why not mark all
 239       * items in the same course as accessible or not accessible.
 240       *
 241       * Helpful methods: {@link set_accessible()} and {@link walk()}
 242       *
 243       * @return null|object
 244       */
 245      public function has_item_that_needs_access_check() {
 246          if (is_siteadmin()) {
 247              return null;
 248          }
 249          $this->retrieve_items();
 250          $counter = 0; // Counter for accessible items.
 251          foreach ($this->itemkeys as $id) {
 252              if (!array_key_exists($id, $this->accessibleitems)) {
 253                  return (object)(array)$this->items[$id];
 254              }
 255              $counter += $this->accessibleitems[$id] ? 1 : 0;
 256              if ($counter >= $this->from + $this->limit) {
 257                  // We found enough accessible items fot get_items() method, do not look any further.
 258                  return null;
 259              }
 260          }
 261          return null;
 262      }
 263  
 264      /**
 265       * Walk through the array of items and call $callable for each of them
 266       * @param callable $callable
 267       */
 268      public function walk($callable) {
 269          $this->retrieve_items();
 270          array_walk($this->items, $callable);
 271      }
 272  
 273      /**
 274       * Marks record or group of records as accessible (or not accessible)
 275       *
 276       * @param int|std_Class $identifier either record id of the item that needs to be set accessible
 277       * @param bool $accessible whether to mark as accessible or not accessible (default true)
 278       */
 279      public function set_accessible($identifier, $accessible = true) {
 280          if (is_object($identifier)) {
 281              $identifier = (int)($identifier->{$this->idfield});
 282          }
 283          $this->init_items_access();
 284          if (is_int($identifier)) {
 285              $accessible = (int)(bool)$accessible;
 286              if (!array_key_exists($identifier, $this->accessibleitems) ||
 287                      $this->accessibleitems[$identifier] != $accessible) {
 288                  $this->accessibleitems[$identifier] = $accessible;
 289                  $this->cachechangedaccessible;
 290              }
 291          } else {
 292              throw new coding_exception('Argument $identifier must be either int or object');
 293          }
 294      }
 295  
 296      /**
 297       * Retrieves a course record (only fields id,visible,fullname,shortname,cacherev).
 298       *
 299       * This method is useful because it also caches results and preloads course context.
 300       *
 301       * @param int $courseid
 302       */
 303      public function get_course($courseid) {
 304          global $DB;
 305          if (!array_key_exists($courseid, $this->courses)) {
 306              $ctxquery = context_helper::get_preload_record_columns_sql('ctx');
 307              $sql = "SELECT c.id,c.visible,c.fullname,c.shortname,c.cacherev, $ctxquery
 308              FROM {course} c JOIN {context} ctx ON ctx.contextlevel = ? AND ctx.instanceid=c.id
 309              WHERE c.id = ?";
 310              $params = array(CONTEXT_COURSE, $courseid);
 311              $this->courses[$courseid] = $DB->get_record_sql($sql, $params);
 312              context_helper::preload_from_record($this->courses[$courseid]);
 313          }
 314          return $this->courses[$courseid];
 315      }
 316  
 317      /**
 318       * Ensures that we read the course access from the cache.
 319       */
 320      protected function init_course_access() {
 321          if ($this->courseaccess === null) {
 322              $this->courseaccess = cache::make('core', 'tagindexbuilder')->get('courseaccess') ?: [];
 323          }
 324      }
 325  
 326      /**
 327       * Ensures that we read the items access from the cache.
 328       */
 329      protected function init_items_access() {
 330          if ($this->accessibleitems === null) {
 331              $this->accessibleitems = cache::make('core', 'tagindexbuilder')->get($this->component.'__'.$this->itemtype) ?: [];
 332          }
 333      }
 334  
 335      /**
 336       * Checks if current user has access to the course
 337       *
 338       * This method calls global function {@link can_access_course} and caches results
 339       *
 340       * @param int $courseid
 341       * @return bool
 342       */
 343      public function can_access_course($courseid) {
 344          $this->init_course_access();
 345          if (!array_key_exists($courseid, $this->courseaccess)) {
 346              $this->courseaccess[$courseid] = can_access_course($this->get_course($courseid)) ? 1 : 0;
 347              $this->cachechangedcourse = true;
 348          }
 349          return $this->courseaccess[$courseid];
 350      }
 351  
 352      /**
 353       * Saves course/items caches if needed
 354       */
 355      protected function save_caches() {
 356          if ($this->cachechangedcourse) {
 357              cache::make('core', 'tagindexbuilder')->set('courseaccess', $this->courseaccess);
 358              $this->cachechangedcourse = false;
 359          }
 360          if ($this->cachechangedaccessible) {
 361              cache::make('core', 'tagindexbuilder')->set($this->component.'__'.$this->itemtype,
 362                      $this->accessibleitems);
 363              $this->cachechangedaccessible = false;
 364          }
 365      }
 366  
 367      /**
 368       * Resets all course/items session caches - useful in unittests when we change users and enrolments.
 369       */
 370      public static function reset_caches() {
 371          cache_helper::purge_by_event('resettagindexbuilder');
 372      }
 373  }