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 311 and 401] [Versions 39 and 401] [Versions 400 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  /**
  18   * Contains class core_tag_tag
  19   *
  20   * @package   core_tag
  21   * @copyright  2015 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   * Represents one tag and also contains lots of useful tag-related methods as static functions.
  29   *
  30   * Tags can be added to any database records.
  31   * $itemtype refers to the DB table name
  32   * $itemid refers to id field in this DB table
  33   * $component is the component that is responsible for the tag instance
  34   * $context is the affected context
  35   *
  36   * BASIC INSTRUCTIONS :
  37   *  - to "tag a blog post" (for example):
  38   *        core_tag_tag::set_item_tags('post', 'core', $blogpost->id, $context, $arrayoftags);
  39   *
  40   *  - to "remove all the tags on a blog post":
  41   *        core_tag_tag::remove_all_item_tags('post', 'core', $blogpost->id);
  42   *
  43   * set_item_tags() will create tags that do not exist yet.
  44   *
  45   * @property-read int $id
  46   * @property-read string $name
  47   * @property-read string $rawname
  48   * @property-read int $tagcollid
  49   * @property-read int $userid
  50   * @property-read int $isstandard
  51   * @property-read string $description
  52   * @property-read int $descriptionformat
  53   * @property-read int $flag 0 if not flagged or positive integer if flagged
  54   * @property-read int $timemodified
  55   *
  56   * @package   core_tag
  57   * @copyright  2015 Marina Glancy
  58   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  59   */
  60  class core_tag_tag {
  61  
  62      /** @var stdClass data about the tag */
  63      protected $record = null;
  64  
  65      /** @var int indicates that both standard and not standard tags can be used (or should be returned) */
  66      const BOTH_STANDARD_AND_NOT = 0;
  67  
  68      /** @var int indicates that only standard tags can be used (or must be returned) */
  69      const STANDARD_ONLY = 1;
  70  
  71      /** @var int indicates that only non-standard tags should be returned - this does not really have use cases, left for BC  */
  72      const NOT_STANDARD_ONLY = -1;
  73  
  74      /** @var int option to hide standard tags when editing item tags */
  75      const HIDE_STANDARD = 2;
  76  
  77      /**
  78       * Constructor. Use functions get(), get_by_name(), etc.
  79       *
  80       * @param stdClass $record
  81       */
  82      protected function __construct($record) {
  83          if (empty($record->id)) {
  84              throw new coding_exception("Record must contain at least field 'id'");
  85          }
  86          $this->record = $record;
  87      }
  88  
  89      /**
  90       * Magic getter
  91       *
  92       * @param string $name
  93       * @return mixed
  94       */
  95      public function __get($name) {
  96          return $this->record->$name;
  97      }
  98  
  99      /**
 100       * Magic isset method
 101       *
 102       * @param string $name
 103       * @return bool
 104       */
 105      public function __isset($name) {
 106          return isset($this->record->$name);
 107      }
 108  
 109      /**
 110       * Converts to object
 111       *
 112       * @return stdClass
 113       */
 114      public function to_object() {
 115          return fullclone($this->record);
 116      }
 117  
 118      /**
 119       * Returns tag name ready to be displayed
 120       *
 121       * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
 122       * @return string
 123       */
 124      public function get_display_name($ashtml = true) {
 125          return static::make_display_name($this->record, $ashtml);
 126      }
 127  
 128      /**
 129       * Prepares tag name ready to be displayed
 130       *
 131       * @param stdClass|core_tag_tag $tag record from db table tag, must contain properties name and rawname
 132       * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
 133       * @return string
 134       */
 135      public static function make_display_name($tag, $ashtml = true) {
 136          global $CFG;
 137  
 138          if (empty($CFG->keeptagnamecase)) {
 139              // This is the normalized tag name.
 140              $tagname = core_text::strtotitle($tag->name);
 141          } else {
 142              // Original casing of the tag name.
 143              $tagname = $tag->rawname;
 144          }
 145  
 146          // Clean up a bit just in case the rules change again.
 147          $tagname = clean_param($tagname, PARAM_TAG);
 148  
 149          return $ashtml ? htmlspecialchars($tagname, ENT_COMPAT) : $tagname;
 150      }
 151  
 152      /**
 153       * Adds one or more tag in the database.  This function should not be called directly : you should
 154       * use tag_set.
 155       *
 156       * @param   int      $tagcollid
 157       * @param   string|array $tags     one tag, or an array of tags, to be created
 158       * @param   bool     $isstandard type of tag to be created. A standard tag is kept even if there are no records tagged with it.
 159       * @return  array    tag objects indexed by their lowercase normalized names. Any boolean false in the array
 160       *                             indicates an error while adding the tag.
 161       */
 162      protected static function add($tagcollid, $tags, $isstandard = false) {
 163          global $USER, $DB;
 164  
 165          $tagobject = new stdClass();
 166          $tagobject->isstandard   = $isstandard ? 1 : 0;
 167          $tagobject->userid       = $USER->id;
 168          $tagobject->timemodified = time();
 169          $tagobject->tagcollid    = $tagcollid;
 170  
 171          $rv = array();
 172          foreach ($tags as $veryrawname) {
 173              $rawname = clean_param($veryrawname, PARAM_TAG);
 174              if (!$rawname) {
 175                  $rv[$rawname] = false;
 176              } else {
 177                  $obj = (object)(array)$tagobject;
 178                  $obj->rawname = $rawname;
 179                  $obj->name    = core_text::strtolower($rawname);
 180                  $obj->id      = $DB->insert_record('tag', $obj);
 181                  $rv[$obj->name] = new static($obj);
 182  
 183                  \core\event\tag_created::create_from_tag($rv[$obj->name])->trigger();
 184              }
 185          }
 186  
 187          return $rv;
 188      }
 189  
 190      /**
 191       * Simple function to just return a single tag object by its id
 192       *
 193       * @param    int    $id
 194       * @param    string $returnfields which fields do we want returned from table {tag}.
 195       *                        Default value is 'id,name,rawname,tagcollid',
 196       *                        specify '*' to include all fields.
 197       * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
 198       *                        IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
 199       *                        MUST_EXIST means throw exception if no record or multiple records found
 200       * @return   core_tag_tag|false  tag object
 201       */
 202      public static function get($id, $returnfields = 'id, name, rawname, tagcollid', $strictness = IGNORE_MISSING) {
 203          global $DB;
 204          $record = $DB->get_record('tag', array('id' => $id), $returnfields, $strictness);
 205          if ($record) {
 206              return new static($record);
 207          }
 208          return false;
 209      }
 210  
 211      /**
 212       * Simple function to just return an array of tag objects by their ids
 213       *
 214       * @param    int[]  $ids
 215       * @param    string $returnfields which fields do we want returned from table {tag}.
 216       *                        Default value is 'id,name,rawname,tagcollid',
 217       *                        specify '*' to include all fields.
 218       * @return   core_tag_tag[] array of retrieved tags
 219       */
 220      public static function get_bulk($ids, $returnfields = 'id, name, rawname, tagcollid') {
 221          global $DB;
 222          $result = array();
 223          if (empty($ids)) {
 224              return $result;
 225          }
 226          list($sql, $params) = $DB->get_in_or_equal($ids);
 227          $records = $DB->get_records_select('tag', 'id '.$sql, $params, '', $returnfields);
 228          foreach ($records as $record) {
 229              $result[$record->id] = new static($record);
 230          }
 231          return $result;
 232      }
 233  
 234      /**
 235       * Simple function to just return a single tag object by tagcollid and name
 236       *
 237       * @param int $tagcollid tag collection to use,
 238       *        if 0 is given we will try to guess the tag collection and return the first match
 239       * @param string $name tag name
 240       * @param string $returnfields which fields do we want returned. This is a comma separated string
 241       *         containing any combination of 'id', 'name', 'rawname', 'tagcollid' or '*' to include all fields.
 242       * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
 243       *                        IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
 244       *                        MUST_EXIST means throw exception if no record or multiple records found
 245       * @return core_tag_tag|false tag object
 246       */
 247      public static function get_by_name($tagcollid, $name, $returnfields='id, name, rawname, tagcollid',
 248                          $strictness = IGNORE_MISSING) {
 249          global $DB;
 250          if ($tagcollid == 0) {
 251              $tags = static::guess_by_name($name, $returnfields);
 252              if ($tags) {
 253                  $tag = reset($tags);
 254                  return $tag;
 255              } else if ($strictness == MUST_EXIST) {
 256                  throw new dml_missing_record_exception('tag', 'name=?', array($name));
 257              }
 258              return false;
 259          }
 260          $name = core_text::strtolower($name);   // To cope with input that might just be wrong case.
 261          $params = array('name' => $name, 'tagcollid' => $tagcollid);
 262          $record = $DB->get_record('tag', $params, $returnfields, $strictness);
 263          if ($record) {
 264              return new static($record);
 265          }
 266          return false;
 267      }
 268  
 269      /**
 270       * Looking in all tag collections for the tag with the given name
 271       *
 272       * @param string $name tag name
 273       * @param string $returnfields
 274       * @return array array of core_tag_tag instances
 275       */
 276      public static function guess_by_name($name, $returnfields='id, name, rawname, tagcollid') {
 277          global $DB;
 278          if (empty($name)) {
 279              return array();
 280          }
 281          $tagcolls = core_tag_collection::get_collections();
 282          list($sql, $params) = $DB->get_in_or_equal(array_keys($tagcolls), SQL_PARAMS_NAMED);
 283          $params['name'] = core_text::strtolower($name);
 284          $tags = $DB->get_records_select('tag', 'name = :name AND tagcollid ' . $sql, $params, '', $returnfields);
 285          if (count($tags) > 1) {
 286              // Sort in the same order as tag collections.
 287              $tagcolls = core_tag_collection::get_collections();
 288              uasort($tags, function($a, $b) use ($tagcolls) {
 289                  return $tagcolls[$a->tagcollid]->sortorder < $tagcolls[$b->tagcollid]->sortorder ? -1 : 1;
 290              });
 291          }
 292          $rv = array();
 293          foreach ($tags as $id => $tag) {
 294              $rv[$id] = new static($tag);
 295          }
 296          return $rv;
 297      }
 298  
 299      /**
 300       * Returns the list of tag objects by tag collection id and the list of tag names
 301       *
 302       * @param    int   $tagcollid
 303       * @param    array $tags array of tags to look for
 304       * @param    string $returnfields list of DB fields to return, must contain 'id', 'name' and 'rawname'
 305       * @return   array tag-indexed array of objects. No value for a key means the tag wasn't found.
 306       */
 307      public static function get_by_name_bulk($tagcollid, $tags, $returnfields = 'id, name, rawname, tagcollid') {
 308          global $DB;
 309  
 310          if (empty($tags)) {
 311              return array();
 312          }
 313  
 314          $cleantags = self::normalize(self::normalize($tags, false)); // Format: rawname => normalised name.
 315  
 316          list($namesql, $params) = $DB->get_in_or_equal(array_values($cleantags));
 317          array_unshift($params, $tagcollid);
 318  
 319          $recordset = $DB->get_recordset_sql("SELECT $returnfields FROM {tag} WHERE tagcollid = ? AND name $namesql", $params);
 320  
 321          $result = array_fill_keys($cleantags, null);
 322          foreach ($recordset as $record) {
 323              $result[$record->name] = new static($record);
 324          }
 325          $recordset->close();
 326          return $result;
 327      }
 328  
 329  
 330      /**
 331       * Function that normalizes a list of tag names.
 332       *
 333       * @param   array        $rawtags array of tags
 334       * @param   bool         $tolowercase convert to lower case?
 335       * @return  array        lowercased normalized tags, indexed by the normalized tag, in the same order as the original array.
 336       *                       (Eg: 'Banana' => 'banana').
 337       */
 338      public static function normalize($rawtags, $tolowercase = true) {
 339          $result = array();
 340          foreach ($rawtags as $rawtag) {
 341              $rawtag = trim($rawtag);
 342              if (strval($rawtag) !== '') {
 343                  $clean = clean_param($rawtag, PARAM_TAG);
 344                  if ($tolowercase) {
 345                      $result[$rawtag] = core_text::strtolower($clean);
 346                  } else {
 347                      $result[$rawtag] = $clean;
 348                  }
 349              }
 350          }
 351          return $result;
 352      }
 353  
 354      /**
 355       * Retrieves tags and/or creates them if do not exist yet
 356       *
 357       * @param int $tagcollid
 358       * @param array $tags array of raw tag names, do not have to be normalised
 359       * @param bool $isstandard create as standard tag (default false)
 360       * @return core_tag_tag[] array of tag objects indexed with lowercase normalised tag name
 361       */
 362      public static function create_if_missing($tagcollid, $tags, $isstandard = false) {
 363          $cleantags = self::normalize(array_filter(self::normalize($tags, false))); // Array rawname => normalised name .
 364  
 365          $result = static::get_by_name_bulk($tagcollid, $tags, '*');
 366          $existing = array_filter($result);
 367          $missing = array_diff_key(array_flip($cleantags), $existing); // Array normalised name => rawname.
 368          if ($missing) {
 369              $newtags = static::add($tagcollid, array_values($missing), $isstandard);
 370              foreach ($newtags as $tag) {
 371                  $result[$tag->name] = $tag;
 372              }
 373          }
 374          return $result;
 375      }
 376  
 377      /**
 378       * Creates a URL to view a tag
 379       *
 380       * @param int $tagcollid
 381       * @param string $name
 382       * @param int $exclusivemode
 383       * @param int $fromctx context id where this tag cloud is displayed
 384       * @param int $ctx context id for tag view link
 385       * @param int $rec recursive argument for tag view link
 386       * @return \moodle_url
 387       */
 388      public static function make_url($tagcollid, $name, $exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
 389          $coll = core_tag_collection::get_by_id($tagcollid);
 390          if (!empty($coll->customurl)) {
 391              $url = '/' . ltrim(trim($coll->customurl), '/');
 392          } else {
 393              $url = '/tag/index.php';
 394          }
 395          $params = array('tc' => $tagcollid, 'tag' => $name);
 396          if ($exclusivemode) {
 397              $params['excl'] = 1;
 398          }
 399          if ($fromctx) {
 400              $params['from'] = $fromctx;
 401          }
 402          if ($ctx) {
 403              $params['ctx'] = $ctx;
 404          }
 405          if (!$rec) {
 406              $params['rec'] = 0;
 407          }
 408          return new moodle_url($url, $params);
 409      }
 410  
 411      /**
 412       * Returns URL to view the tag
 413       *
 414       * @param int $exclusivemode
 415       * @param int $fromctx context id where this tag cloud is displayed
 416       * @param int $ctx context id for tag view link
 417       * @param int $rec recursive argument for tag view link
 418       * @return \moodle_url
 419       */
 420      public function get_view_url($exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
 421          return static::make_url($this->record->tagcollid, $this->record->rawname,
 422              $exclusivemode, $fromctx, $ctx, $rec);
 423      }
 424  
 425      /**
 426       * Validates that the required fields were retrieved and retrieves them if missing
 427       *
 428       * @param array $list array of the fields that need to be validated
 429       * @param string $caller name of the function that requested it, for the debugging message
 430       */
 431      protected function ensure_fields_exist($list, $caller) {
 432          global $DB;
 433          $missing = array_diff($list, array_keys((array)$this->record));
 434          if ($missing) {
 435              debugging('core_tag_tag::' . $caller . '() must be called on fully retrieved tag object. Missing fields: '.
 436                      join(', ', $missing), DEBUG_DEVELOPER);
 437              $this->record = $DB->get_record('tag', array('id' => $this->record->id), '*', MUST_EXIST);
 438          }
 439      }
 440  
 441      /**
 442       * Deletes the tag instance given the record from tag_instance DB table
 443       *
 444       * @param stdClass $taginstance
 445       * @param bool $fullobject whether $taginstance contains all fields from DB table tag_instance
 446       *          (in this case it is safe to add a record snapshot to the event)
 447       * @return bool
 448       */
 449      protected function delete_instance_as_record($taginstance, $fullobject = false) {
 450          global $DB;
 451  
 452          $this->ensure_fields_exist(array('name', 'rawname', 'isstandard'), 'delete_instance_as_record');
 453  
 454          $DB->delete_records('tag_instance', array('id' => $taginstance->id));
 455  
 456          // We can not fire an event with 'null' as the contextid.
 457          if (is_null($taginstance->contextid)) {
 458              $taginstance->contextid = context_system::instance()->id;
 459          }
 460  
 461          // Trigger tag removed event.
 462          $taginstance->tagid = $this->id;
 463          \core\event\tag_removed::create_from_tag_instance($taginstance, $this->name, $this->rawname, $fullobject)->trigger();
 464  
 465          // If there are no other instances of the tag then consider deleting the tag as well.
 466          if (!$this->isstandard) {
 467              if (!$DB->record_exists('tag_instance', array('tagid' => $this->id))) {
 468                  self::delete_tags($this->id);
 469              }
 470          }
 471  
 472          return true;
 473      }
 474  
 475      /**
 476       * Delete one instance of a tag.  If the last instance was deleted, it will also delete the tag, unless it is standard.
 477       *
 478       * @param    string $component component responsible for tagging. For BC it can be empty but in this case the
 479       *                  query will be slow because DB index will not be used.
 480       * @param    string $itemtype the type of the record for which to remove the instance
 481       * @param    int    $itemid   the id of the record for which to remove the instance
 482       * @param    int    $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 483       */
 484      protected function delete_instance($component, $itemtype, $itemid, $tiuserid = 0) {
 485          global $DB;
 486          $params = array('tagid' => $this->id,
 487                  'itemtype' => $itemtype, 'itemid' => $itemid);
 488          if ($tiuserid) {
 489              $params['tiuserid'] = $tiuserid;
 490          }
 491          if ($component) {
 492              $params['component'] = $component;
 493          }
 494  
 495          $taginstance = $DB->get_record('tag_instance', $params);
 496          if (!$taginstance) {
 497              return;
 498          }
 499          $this->delete_instance_as_record($taginstance, true);
 500      }
 501  
 502      /**
 503       * Bulk delete all tag instances.
 504       *
 505       * @param stdClass[] $taginstances A list of tag_instance records to delete. Each
 506       *                                 record must also contain the name and rawname
 507       *                                 columns from the related tag record.
 508       */
 509      public static function delete_instances_as_record(array $taginstances) {
 510          global $DB;
 511  
 512          if (empty($taginstances)) {
 513              return;
 514          }
 515  
 516          $taginstanceids = array_map(function($taginstance) {
 517              return $taginstance->id;
 518          }, $taginstances);
 519          // Now remove all the tag instances.
 520          $DB->delete_records_list('tag_instance', 'id', $taginstanceids);
 521          // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
 522          $syscontextid = context_system::instance()->id;
 523          // Loop through the tag instances and fire an 'tag_removed' event.
 524          foreach ($taginstances as $taginstance) {
 525              // We can not fire an event with 'null' as the contextid.
 526              if (is_null($taginstance->contextid)) {
 527                  $taginstance->contextid = $syscontextid;
 528              }
 529  
 530              // Trigger tag removed event.
 531              \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name,
 532                      $taginstance->rawname, true)->trigger();
 533          }
 534      }
 535  
 536      /**
 537       * Bulk delete all tag instances by tag id.
 538       *
 539       * @param int[] $taginstanceids List of tag instance ids to be deleted.
 540       */
 541      public static function delete_instances_by_id(array $taginstanceids) {
 542          global $DB;
 543  
 544          if (empty($taginstanceids)) {
 545              return;
 546          }
 547  
 548          list($idsql, $params) = $DB->get_in_or_equal($taginstanceids);
 549          $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
 550                    FROM {tag_instance} ti
 551                    JOIN {tag} t
 552                      ON ti.tagid = t.id
 553                   WHERE ti.id {$idsql}";
 554  
 555          if ($taginstances = $DB->get_records_sql($sql, $params)) {
 556              static::delete_instances_as_record($taginstances);
 557          }
 558      }
 559  
 560      /**
 561       * Bulk delete all tag instances for a component or tag area
 562       *
 563       * @param string $component
 564       * @param string $itemtype (optional)
 565       * @param int $contextid (optional)
 566       */
 567      public static function delete_instances($component, $itemtype = null, $contextid = null) {
 568          global $DB;
 569  
 570          $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
 571                    FROM {tag_instance} ti
 572                    JOIN {tag} t
 573                      ON ti.tagid = t.id
 574                   WHERE ti.component = :component";
 575          $params = array('component' => $component);
 576          if (!is_null($contextid)) {
 577              $sql .= " AND ti.contextid = :contextid";
 578              $params['contextid'] = $contextid;
 579          }
 580          if (!is_null($itemtype)) {
 581              $sql .= " AND ti.itemtype = :itemtype";
 582              $params['itemtype'] = $itemtype;
 583          }
 584  
 585          if ($taginstances = $DB->get_records_sql($sql, $params)) {
 586              static::delete_instances_as_record($taginstances);
 587          }
 588      }
 589  
 590      /**
 591       * Adds a tag instance
 592       *
 593       * @param string $component
 594       * @param string $itemtype
 595       * @param string $itemid
 596       * @param context $context
 597       * @param int $ordering
 598       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 599       * @return int id of tag_instance
 600       */
 601      protected function add_instance($component, $itemtype, $itemid, context $context, $ordering, $tiuserid = 0) {
 602          global $DB;
 603          $this->ensure_fields_exist(array('name', 'rawname'), 'add_instance');
 604  
 605          $taginstance = new stdClass;
 606          $taginstance->tagid        = $this->id;
 607          $taginstance->component    = $component ? $component : '';
 608          $taginstance->itemid       = $itemid;
 609          $taginstance->itemtype     = $itemtype;
 610          $taginstance->contextid    = $context->id;
 611          $taginstance->ordering     = $ordering;
 612          $taginstance->timecreated  = time();
 613          $taginstance->timemodified = $taginstance->timecreated;
 614          $taginstance->tiuserid     = $tiuserid;
 615  
 616          $taginstance->id = $DB->insert_record('tag_instance', $taginstance);
 617  
 618          // Trigger tag added event.
 619          \core\event\tag_added::create_from_tag_instance($taginstance, $this->name, $this->rawname, true)->trigger();
 620  
 621          return $taginstance->id;
 622      }
 623  
 624      /**
 625       * Updates the ordering on tag instance
 626       *
 627       * @param int $instanceid
 628       * @param int $ordering
 629       */
 630      protected function update_instance_ordering($instanceid, $ordering) {
 631          global $DB;
 632          $data = new stdClass();
 633          $data->id = $instanceid;
 634          $data->ordering = $ordering;
 635          $data->timemodified = time();
 636  
 637          $DB->update_record('tag_instance', $data);
 638      }
 639  
 640      /**
 641       * Get the array of core_tag_tag objects associated with a list of items.
 642       *
 643       * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
 644       *
 645       * @param string $component component responsible for tagging. For BC it can be empty but in this case the
 646       *               query will be slow because DB index will not be used.
 647       * @param string $itemtype type of the tagged item
 648       * @param int[] $itemids
 649       * @param int $standardonly wether to return only standard tags or any
 650       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
 651       * @return core_tag_tag[][] first array key is itemid. For each itemid,
 652       *      an array tagid => tag object with additional fields taginstanceid, taginstancecontextid and ordering
 653       */
 654      public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT,
 655              $tiuserid = 0) {
 656          global $DB;
 657  
 658          if (static::is_enabled($component, $itemtype) === false) {
 659              // Tagging area is properly defined but not enabled - return empty array.
 660              return array();
 661          }
 662  
 663          if (empty($itemids)) {
 664              return array();
 665          }
 666  
 667          $standardonly = (int)$standardonly; // In case somebody passed bool.
 668  
 669          list($idsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
 670          // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags().
 671          $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
 672                      tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
 673                    FROM {tag_instance} ti
 674                    JOIN {tag} tg ON tg.id = ti.tagid
 675                    WHERE ti.itemtype = :itemtype AND ti.itemid $idsql ".
 676                  ($component ? "AND ti.component = :component " : "").
 677                  ($tiuserid ? "AND ti.tiuserid = :tiuserid " : "").
 678                  (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : "").
 679                  (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : "").
 680                 "ORDER BY ti.ordering ASC, ti.id";
 681  
 682          $params['itemtype'] = $itemtype;
 683          $params['component'] = $component;
 684          $params['tiuserid'] = $tiuserid;
 685  
 686          $records = $DB->get_records_sql($sql, $params);
 687          $result = array();
 688          foreach ($itemids as $itemid) {
 689              $result[$itemid] = [];
 690          }
 691          foreach ($records as $id => $record) {
 692              $result[$record->itemid][$id] = new static($record);
 693          }
 694          return $result;
 695      }
 696  
 697      /**
 698       * Get the array of core_tag_tag objects associated with an item (instances).
 699       *
 700       * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
 701       *
 702       * @param string $component component responsible for tagging. For BC it can be empty but in this case the
 703       *               query will be slow because DB index will not be used.
 704       * @param string $itemtype type of the tagged item
 705       * @param int $itemid
 706       * @param int $standardonly wether to return only standard tags or any
 707       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
 708       * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
 709       */
 710      public static function get_item_tags($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
 711              $tiuserid = 0) {
 712          $tagobjects = static::get_items_tags($component, $itemtype, [$itemid], $standardonly, $tiuserid);
 713          return empty($tagobjects) ? [] : $tagobjects[$itemid];
 714      }
 715  
 716      /**
 717       * Returns the list of display names of the tags that are associated with an item
 718       *
 719       * This method is usually used to prefill the form data for the 'tags' form element
 720       *
 721       * @param string $component component responsible for tagging. For BC it can be empty but in this case the
 722       *               query will be slow because DB index will not be used.
 723       * @param string $itemtype type of the tagged item
 724       * @param int $itemid
 725       * @param int $standardonly wether to return only standard tags or any
 726       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
 727       * @param bool $ashtml (default true) if true will return htmlspecialchars encoded tag names
 728       * @return string[] array of tags display names
 729       */
 730      public static function get_item_tags_array($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
 731              $tiuserid = 0, $ashtml = true) {
 732          $tags = array();
 733          foreach (static::get_item_tags($component, $itemtype, $itemid, $standardonly, $tiuserid) as $tag) {
 734              $tags[$tag->id] = $tag->get_display_name($ashtml);
 735          }
 736          return $tags;
 737      }
 738  
 739      /**
 740       * Sets the list of tag instances for one item (table record).
 741       *
 742       * Extra exsisting instances are removed, new ones are added. New tags are created if needed.
 743       *
 744       * This method can not be used for setting tags relations, please use set_related_tags()
 745       *
 746       * @param string $component component responsible for tagging
 747       * @param string $itemtype type of the tagged item
 748       * @param int $itemid
 749       * @param context $context
 750       * @param array $tagnames
 751       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 752       */
 753      public static function set_item_tags($component, $itemtype, $itemid, context $context, $tagnames, $tiuserid = 0) {
 754          if ($itemtype === 'tag') {
 755              if ($tiuserid) {
 756                  throw new coding_exception('Related tags can not have tag instance userid');
 757              }
 758              debugging('You can not use set_item_tags() for tagging a tag, please use set_related_tags()', DEBUG_DEVELOPER);
 759              static::get($itemid, '*', MUST_EXIST)->set_related_tags($tagnames);
 760              return;
 761          }
 762  
 763          if ($tagnames !== null && static::is_enabled($component, $itemtype) === false) {
 764              // Tagging area is properly defined but not enabled - do nothing.
 765              // Unless we are deleting the item tags ($tagnames === null), in which case proceed with deleting.
 766              return;
 767          }
 768  
 769          // Apply clean_param() to all tags.
 770          if ($tagnames) {
 771              $tagcollid = core_tag_area::get_collection($component, $itemtype);
 772              $tagobjects = static::create_if_missing($tagcollid, $tagnames);
 773          } else {
 774              $tagobjects = array();
 775          }
 776  
 777          $allowmultiplecontexts = core_tag_area::allows_tagging_in_multiple_contexts($component, $itemtype);
 778          $currenttags = static::get_item_tags($component, $itemtype, $itemid, self::BOTH_STANDARD_AND_NOT, $tiuserid);
 779          $taginstanceidstomovecontext = [];
 780  
 781          // For data coherence reasons, it's better to remove deleted tags
 782          // before adding new data: ordering could be duplicated.
 783          foreach ($currenttags as $currenttag) {
 784              $hasbeenrequested = array_key_exists($currenttag->name, $tagobjects);
 785              $issamecontext = $currenttag->taginstancecontextid == $context->id;
 786  
 787              if ($allowmultiplecontexts) {
 788                  // If the tag area allows multiple contexts then we should only be
 789                  // managing tags in the given $context. All other tags can be ignored.
 790                  $shoulddelete = $issamecontext && !$hasbeenrequested;
 791              } else {
 792                  // If the tag area only allows tag instances in a single context then
 793                  // all tags that aren't in the requested tags should be deleted, regardless
 794                  // of their context, if they are not part of the new set of tags.
 795                  $shoulddelete = !$hasbeenrequested;
 796                  // If the tag instance isn't in the correct context (legacy data)
 797                  // then we should take this opportunity to update it with the correct
 798                  // context id.
 799                  if (!$shoulddelete && !$issamecontext) {
 800                      $currenttag->taginstancecontextid = $context->id;
 801                      $taginstanceidstomovecontext[] = $currenttag->taginstanceid;
 802                  }
 803              }
 804  
 805              if ($shoulddelete) {
 806                  $taginstance = (object)array('id' => $currenttag->taginstanceid,
 807                      'itemtype' => $itemtype, 'itemid' => $itemid,
 808                      'contextid' => $currenttag->taginstancecontextid, 'tiuserid' => $tiuserid);
 809                  $currenttag->delete_instance_as_record($taginstance, false);
 810              }
 811          }
 812  
 813          if (!empty($taginstanceidstomovecontext)) {
 814              static::change_instances_context($taginstanceidstomovecontext, $context);
 815          }
 816  
 817          $ordering = -1;
 818          foreach ($tagobjects as $name => $tag) {
 819              $ordering++;
 820              foreach ($currenttags as $currenttag) {
 821                  $namesmatch = strval($currenttag->name) === strval($name);
 822  
 823                  if ($allowmultiplecontexts) {
 824                      // If the tag area allows multiple contexts then we should only
 825                      // skip adding a new instance if the existing one is in the correct
 826                      // context.
 827                      $contextsmatch = $currenttag->taginstancecontextid == $context->id;
 828                      $shouldskipinstance = $namesmatch && $contextsmatch;
 829                  } else {
 830                      // The existing behaviour for single context tag areas is to
 831                      // skip adding a new instance regardless of whether the existing
 832                      // instance is in the same context as the provided $context.
 833                      $shouldskipinstance = $namesmatch;
 834                  }
 835  
 836                  if ($shouldskipinstance) {
 837                      if ($currenttag->ordering != $ordering) {
 838                          $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering);
 839                      }
 840                      continue 2;
 841                  }
 842              }
 843              $tag->add_instance($component, $itemtype, $itemid, $context, $ordering, $tiuserid);
 844          }
 845      }
 846  
 847      /**
 848       * Removes all tags from an item.
 849       *
 850       * All tags will be removed even if tagging is disabled in this area. This is
 851       * usually called when the item itself has been deleted.
 852       *
 853       * @param string $component component responsible for tagging
 854       * @param string $itemtype type of the tagged item
 855       * @param int $itemid
 856       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 857       */
 858      public static function remove_all_item_tags($component, $itemtype, $itemid, $tiuserid = 0) {
 859          $context = context_system::instance(); // Context will not be used.
 860          static::set_item_tags($component, $itemtype, $itemid, $context, null, $tiuserid);
 861      }
 862  
 863      /**
 864       * Adds a tag to an item, without overwriting the current tags.
 865       *
 866       * If the tag has already been added to the record, no changes are made.
 867       *
 868       * @param string $component the component that was tagged
 869       * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
 870       * @param int $itemid the id of the record to tag
 871       * @param context $context the context of where this tag was assigned
 872       * @param string $tagname the tag to add
 873       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 874       * @return int id of tag_instance that was either created or already existed or null if tagging is not enabled
 875       */
 876      public static function add_item_tag($component, $itemtype, $itemid, context $context, $tagname, $tiuserid = 0) {
 877          global $DB;
 878  
 879          if (static::is_enabled($component, $itemtype) === false) {
 880              // Tagging area is properly defined but not enabled - do nothing.
 881              return null;
 882          }
 883  
 884          $rawname = clean_param($tagname, PARAM_TAG);
 885          $normalisedname = core_text::strtolower($rawname);
 886          $tagcollid = core_tag_area::get_collection($component, $itemtype);
 887  
 888          $usersql = $tiuserid ? " AND ti.tiuserid = :tiuserid " : "";
 889          $sql = 'SELECT t.*, ti.id AS taginstanceid
 890                  FROM {tag} t
 891                  LEFT JOIN {tag_instance} ti ON ti.tagid = t.id AND ti.itemtype = :itemtype '.
 892                  $usersql .
 893                  'AND ti.itemid = :itemid AND ti.component = :component
 894                  WHERE t.name = :name AND t.tagcollid = :tagcollid';
 895          $params = array('name' => $normalisedname, 'tagcollid' => $tagcollid, 'itemtype' => $itemtype,
 896              'itemid' => $itemid, 'component' => $component, 'tiuserid' => $tiuserid);
 897          $record = $DB->get_record_sql($sql, $params);
 898          if ($record) {
 899              if ($record->taginstanceid) {
 900                  // Tag was already added to the item, nothing to do here.
 901                  return $record->taginstanceid;
 902              }
 903              $tag = new static($record);
 904          } else {
 905              // The tag does not exist yet, create it.
 906              $tags = static::add($tagcollid, array($tagname));
 907              $tag = reset($tags);
 908          }
 909  
 910          $ordering = $DB->get_field_sql('SELECT MAX(ordering) FROM {tag_instance} ti
 911                  WHERE ti.itemtype = :itemtype AND ti.itemid = :itemid AND
 912                  ti.component = :component' . $usersql, $params);
 913  
 914          return $tag->add_instance($component, $itemtype, $itemid, $context, $ordering + 1, $tiuserid);
 915      }
 916  
 917      /**
 918       * Removes the tag from an item without changing the other tags
 919       *
 920       * @param string $component the component that was tagged
 921       * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
 922       * @param int $itemid the id of the record to tag
 923       * @param string $tagname the tag to remove
 924       * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
 925       */
 926      public static function remove_item_tag($component, $itemtype, $itemid, $tagname, $tiuserid = 0) {
 927          global $DB;
 928  
 929          if (static::is_enabled($component, $itemtype) === false) {
 930              // Tagging area is properly defined but not enabled - do nothing.
 931              return array();
 932          }
 933  
 934          $rawname = clean_param($tagname, PARAM_TAG);
 935          $normalisedname = core_text::strtolower($rawname);
 936  
 937          $usersql = $tiuserid ? " AND tiuserid = :tiuserid " : "";
 938          $componentsql = $component ? " AND ti.component = :component " : "";
 939          $sql = 'SELECT t.*, ti.id AS taginstanceid, ti.contextid AS taginstancecontextid, ti.ordering
 940                  FROM {tag} t JOIN {tag_instance} ti ON ti.tagid = t.id ' . $usersql . '
 941                  WHERE t.name = :name AND ti.itemtype = :itemtype
 942                  AND ti.itemid = :itemid ' . $componentsql;
 943          $params = array('name' => $normalisedname,
 944              'itemtype' => $itemtype, 'itemid' => $itemid, 'component' => $component,
 945              'tiuserid' => $tiuserid);
 946          if ($record = $DB->get_record_sql($sql, $params)) {
 947              $taginstance = (object)array('id' => $record->taginstanceid,
 948                  'itemtype' => $itemtype, 'itemid' => $itemid,
 949                  'contextid' => $record->taginstancecontextid, 'tiuserid' => $tiuserid);
 950              $tag = new static($record);
 951              $tag->delete_instance_as_record($taginstance, false);
 952              $componentsql = $component ? " AND component = :component " : "";
 953              $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
 954                      WHERE itemtype = :itemtype
 955                  AND itemid = :itemid $componentsql $usersql
 956                  AND ordering > :ordering";
 957              $params['ordering'] = $record->ordering;
 958              $DB->execute($sql, $params);
 959          }
 960      }
 961  
 962      /**
 963       * Allows to move all tag instances from one context to another
 964       *
 965       * @param string $component the component that was tagged
 966       * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
 967       * @param context $oldcontext
 968       * @param context $newcontext
 969       */
 970      public static function move_context($component, $itemtype, $oldcontext, $newcontext) {
 971          global $DB;
 972          if ($oldcontext instanceof context) {
 973              $oldcontext = $oldcontext->id;
 974          }
 975          if ($newcontext instanceof context) {
 976              $newcontext = $newcontext->id;
 977          }
 978          $DB->set_field('tag_instance', 'contextid', $newcontext,
 979                  array('component' => $component, 'itemtype' => $itemtype, 'contextid' => $oldcontext));
 980      }
 981  
 982      /**
 983       * Moves all tags of the specified items to the new context
 984       *
 985       * @param string $component the component that was tagged
 986       * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
 987       * @param array $itemids
 988       * @param context|int $newcontext target context to move tags to
 989       */
 990      public static function change_items_context($component, $itemtype, $itemids, $newcontext) {
 991          global $DB;
 992          if (empty($itemids)) {
 993              return;
 994          }
 995          if (!is_array($itemids)) {
 996              $itemids = array($itemids);
 997          }
 998          list($sql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
 999          $params['component'] = $component;
1000          $params['itemtype'] = $itemtype;
1001          if ($newcontext instanceof context) {
1002              $newcontext = $newcontext->id;
1003          }
1004  
1005          $DB->set_field_select('tag_instance', 'contextid', $newcontext,
1006              'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params);
1007      }
1008  
1009      /**
1010       * Moves all of the specified tag instances into a new context.
1011       *
1012       * @param array $taginstanceids The list of tag instance ids that should be moved
1013       * @param context $newcontext The context to move the tag instances into
1014       */
1015      public static function change_instances_context(array $taginstanceids, context $newcontext) {
1016          global $DB;
1017  
1018          if (empty($taginstanceids)) {
1019              return;
1020          }
1021  
1022          list($sql, $params) = $DB->get_in_or_equal($taginstanceids);
1023          $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params);
1024      }
1025  
1026      /**
1027       * Updates the information about the tag
1028       *
1029       * @param array|stdClass $data data to update, may contain: isstandard, description, descriptionformat, rawname
1030       * @return bool whether the tag was updated. False may be returned if: all new values match the existing,
1031       *         or it was attempted to rename the tag to the name that is already used.
1032       */
1033      public function update($data) {
1034          global $DB, $COURSE;
1035  
1036          $allowedfields = array('isstandard', 'description', 'descriptionformat', 'rawname');
1037  
1038          $data = (array)$data;
1039          if ($extrafields = array_diff(array_keys($data), $allowedfields)) {
1040              debugging('The field(s) '.join(', ', $extrafields).' will be ignored when updating the tag',
1041                      DEBUG_DEVELOPER);
1042          }
1043          $data = array_intersect_key($data, array_fill_keys($allowedfields, 1));
1044          $this->ensure_fields_exist(array_merge(array('tagcollid', 'userid', 'name', 'rawname'), array_keys($data)), 'update');
1045  
1046          // Validate the tag name.
1047          if (array_key_exists('rawname', $data)) {
1048              $data['rawname'] = clean_param($data['rawname'], PARAM_TAG);
1049              $name = core_text::strtolower($data['rawname']);
1050  
1051              if (!$name || $data['rawname'] === $this->rawname) {
1052                  unset($data['rawname']);
1053              } else if ($existing = static::get_by_name($this->tagcollid, $name, 'id')) {
1054                  // Prevent the rename if a tag with that name already exists.
1055                  if ($existing->id != $this->id) {
1056                      throw new moodle_exception('namesalreadybeeingused', 'core_tag');
1057                  }
1058              }
1059              if (isset($data['rawname'])) {
1060                  $data['name'] = $name;
1061              }
1062          }
1063  
1064          // Validate the tag type.
1065          if (array_key_exists('isstandard', $data)) {
1066              $data['isstandard'] = $data['isstandard'] ? 1 : 0;
1067          }
1068  
1069          // Find only the attributes that need to be changed.
1070          $originalname = $this->name;
1071          foreach ($data as $key => $value) {
1072              if ($this->record->$key !== $value) {
1073                  $this->record->$key = $value;
1074              } else {
1075                  unset($data[$key]);
1076              }
1077          }
1078          if (empty($data)) {
1079              return false;
1080          }
1081  
1082          $data['id'] = $this->id;
1083          $data['timemodified'] = time();
1084          $DB->update_record('tag', $data);
1085  
1086          $event = \core\event\tag_updated::create(array(
1087              'objectid' => $this->id,
1088              'relateduserid' => $this->userid,
1089              'context' => context_system::instance(),
1090              'other' => array(
1091                  'name' => $this->name,
1092                  'rawname' => $this->rawname
1093              )
1094          ));
1095          if (isset($data['rawname'])) {
1096              $event->set_legacy_logdata(array($COURSE->id, 'tag', 'update', 'index.php?id='. $this->id,
1097                  $originalname . '->'. $this->name));
1098          }
1099          $event->trigger();
1100          return true;
1101      }
1102  
1103      /**
1104       * Flag a tag as inappropriate
1105       */
1106      public function flag() {
1107          global $DB;
1108  
1109          $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1110  
1111          // Update all the tags to flagged.
1112          $this->timemodified = time();
1113          $this->flag++;
1114          $DB->update_record('tag', array('timemodified' => $this->timemodified,
1115              'flag' => $this->flag, 'id' => $this->id));
1116  
1117          $event = \core\event\tag_flagged::create(array(
1118              'objectid' => $this->id,
1119              'relateduserid' => $this->userid,
1120              'context' => context_system::instance(),
1121              'other' => array(
1122                  'name' => $this->name,
1123                  'rawname' => $this->rawname
1124              )
1125  
1126          ));
1127          $event->trigger();
1128      }
1129  
1130      /**
1131       * Remove the inappropriate flag on a tag.
1132       */
1133      public function reset_flag() {
1134          global $DB;
1135  
1136          $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1137  
1138          if (!$this->flag) {
1139              // Nothing to do.
1140              return false;
1141          }
1142  
1143          $this->timemodified = time();
1144          $this->flag = 0;
1145          $DB->update_record('tag', array('timemodified' => $this->timemodified,
1146              'flag' => 0, 'id' => $this->id));
1147  
1148          $event = \core\event\tag_unflagged::create(array(
1149              'objectid' => $this->id,
1150              'relateduserid' => $this->userid,
1151              'context' => context_system::instance(),
1152              'other' => array(
1153                  'name' => $this->name,
1154                  'rawname' => $this->rawname
1155              )
1156          ));
1157          $event->trigger();
1158      }
1159  
1160      /**
1161       * Sets the list of tags related to this one.
1162       *
1163       * Tag relations are recorded by two instances linking two tags to each other.
1164       * For tag relations ordering is not used and may be random.
1165       *
1166       * @param array $tagnames
1167       */
1168      public function set_related_tags($tagnames) {
1169          $context = context_system::instance();
1170          $tagobjects = $tagnames ? static::create_if_missing($this->tagcollid, $tagnames) : array();
1171          unset($tagobjects[$this->name]); // Never link to itself.
1172  
1173          $currenttags = static::get_item_tags('core', 'tag', $this->id);
1174  
1175          // For data coherence reasons, it's better to remove deleted tags
1176          // before adding new data: ordering could be duplicated.
1177          foreach ($currenttags as $currenttag) {
1178              if (!array_key_exists($currenttag->name, $tagobjects)) {
1179                  $taginstance = (object)array('id' => $currenttag->taginstanceid,
1180                      'itemtype' => 'tag', 'itemid' => $this->id,
1181                      'contextid' => $context->id);
1182                  $currenttag->delete_instance_as_record($taginstance, false);
1183                  $this->delete_instance('core', 'tag', $currenttag->id);
1184              }
1185          }
1186  
1187          foreach ($tagobjects as $name => $tag) {
1188              foreach ($currenttags as $currenttag) {
1189                  if ($currenttag->name === $name) {
1190                      continue 2;
1191                  }
1192              }
1193              $this->add_instance('core', 'tag', $tag->id, $context, 0);
1194              $tag->add_instance('core', 'tag', $this->id, $context, 0);
1195              $currenttags[] = $tag;
1196          }
1197      }
1198  
1199      /**
1200       * Adds to the list of related tags without removing existing
1201       *
1202       * Tag relations are recorded by two instances linking two tags to each other.
1203       * For tag relations ordering is not used and may be random.
1204       *
1205       * @param array $tagnames
1206       */
1207      public function add_related_tags($tagnames) {
1208          $context = context_system::instance();
1209          $tagobjects = static::create_if_missing($this->tagcollid, $tagnames);
1210  
1211          $currenttags = static::get_item_tags('core', 'tag', $this->id);
1212  
1213          foreach ($tagobjects as $name => $tag) {
1214              foreach ($currenttags as $currenttag) {
1215                  if ($currenttag->name === $name) {
1216                      continue 2;
1217                  }
1218              }
1219              $this->add_instance('core', 'tag', $tag->id, $context, 0);
1220              $tag->add_instance('core', 'tag', $this->id, $context, 0);
1221              $currenttags[] = $tag;
1222          }
1223      }
1224  
1225      /**
1226       * Returns the correlated tags of a tag, retrieved from the tag_correlation table.
1227       *
1228       * Correlated tags are calculated in cron based on existing tag instances.
1229       *
1230       * @param bool $keepduplicates if true, will return one record for each existing
1231       *      tag instance which may result in duplicates of the actual tags
1232       * @return core_tag_tag[] an array of tag objects
1233       */
1234      public function get_correlated_tags($keepduplicates = false) {
1235          global $DB;
1236  
1237          $correlated = $DB->get_field('tag_correlation', 'correlatedtags', array('tagid' => $this->id));
1238  
1239          if (!$correlated) {
1240              return array();
1241          }
1242          $correlated = preg_split('/\s*,\s*/', trim($correlated), -1, PREG_SPLIT_NO_EMPTY);
1243          list($query, $params) = $DB->get_in_or_equal($correlated);
1244  
1245          // This is (and has to) return the same fields as the query in core_tag_tag::get_item_tags().
1246          $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
1247                  tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
1248                FROM {tag} tg
1249          INNER JOIN {tag_instance} ti ON tg.id = ti.tagid
1250               WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ?
1251            ORDER BY ti.ordering ASC, ti.id";
1252          $params[] = $this->id;
1253          $params[] = $this->tagcollid;
1254          $records = $DB->get_records_sql($sql, $params);
1255          $seen = array();
1256          $result = array();
1257          foreach ($records as $id => $record) {
1258              if (!$keepduplicates && !empty($seen[$record->id])) {
1259                  continue;
1260              }
1261              $result[$id] = new static($record);
1262              $seen[$record->id] = true;
1263          }
1264          return $result;
1265      }
1266  
1267      /**
1268       * Returns tags that this tag was manually set as related to
1269       *
1270       * @return core_tag_tag[]
1271       */
1272      public function get_manual_related_tags() {
1273          return self::get_item_tags('core', 'tag', $this->id);
1274      }
1275  
1276      /**
1277       * Returns tags related to a tag
1278       *
1279       * Related tags of a tag come from two sources:
1280       *   - manually added related tags, which are tag_instance entries for that tag
1281       *   - correlated tags, which are calculated
1282       *
1283       * @return core_tag_tag[] an array of tag objects
1284       */
1285      public function get_related_tags() {
1286          $manual = $this->get_manual_related_tags();
1287          $automatic = $this->get_correlated_tags();
1288          $relatedtags = array_merge($manual, $automatic);
1289  
1290          // Remove duplicated tags (multiple instances of the same tag).
1291          $seen = array();
1292          foreach ($relatedtags as $instance => $tag) {
1293              if (isset($seen[$tag->id])) {
1294                  unset($relatedtags[$instance]);
1295              } else {
1296                  $seen[$tag->id] = 1;
1297              }
1298          }
1299  
1300          return $relatedtags;
1301      }
1302  
1303      /**
1304       * Find all items tagged with a tag of a given type ('post', 'user', etc.)
1305       *
1306       * @param    string   $component component responsible for tagging. For BC it can be empty but in this case the
1307       *                    query will be slow because DB index will not be used.
1308       * @param    string   $itemtype  type to restrict search to
1309       * @param    int      $limitfrom (optional, required if $limitnum is set) return a subset of records, starting at this point.
1310       * @param    int      $limitnum  (optional, required if $limitfrom is set) return a subset comprising this many records.
1311       * @param    string   $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1312       * @param    array    $params additional parameters for the DB query
1313       * @return   array of matching objects, indexed by record id, from the table containing the type requested
1314       */
1315      public function get_tagged_items($component, $itemtype, $limitfrom = '', $limitnum = '', $subquery = '', $params = array()) {
1316          global $DB;
1317  
1318          if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1319              return array();
1320          }
1321          $params = $params ? $params : array();
1322  
1323          $query = "SELECT it.*
1324                      FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1325                     WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1326          $params['itemtype'] = $itemtype;
1327          $params['tagid'] = $this->id;
1328          if ($component) {
1329              $query .= ' AND tt.component = :component';
1330              $params['component'] = $component;
1331          }
1332          if ($subquery) {
1333              $query .= ' AND ' . $subquery;
1334          }
1335          $query .= ' ORDER BY it.id';
1336  
1337          return $DB->get_records_sql($query, $params, $limitfrom, $limitnum);
1338      }
1339  
1340      /**
1341       * Count how many items are tagged with a specific tag.
1342       *
1343       * @param    string   $component component responsible for tagging. For BC it can be empty but in this case the
1344       *                    query will be slow because DB index will not be used.
1345       * @param    string   $itemtype  type to restrict search to
1346       * @param    string   $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1347       * @param    array    $params additional parameters for the DB query
1348       * @return   int      number of mathing tags.
1349       */
1350      public function count_tagged_items($component, $itemtype, $subquery = '', $params = array()) {
1351          global $DB;
1352  
1353          if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1354              return 0;
1355          }
1356          $params = $params ? $params : array();
1357  
1358          $query = "SELECT COUNT(it.id)
1359                      FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1360                     WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1361          $params['itemtype'] = $itemtype;
1362          $params['tagid'] = $this->id;
1363          if ($component) {
1364              $query .= ' AND tt.component = :component';
1365              $params['component'] = $component;
1366          }
1367          if ($subquery) {
1368              $query .= ' AND ' . $subquery;
1369          }
1370  
1371          return $DB->get_field_sql($query, $params);
1372      }
1373  
1374      /**
1375       * Determine if an item is tagged with a specific tag
1376       *
1377       * Note that this is a static method and not a method of core_tag object because the tag might not exist yet,
1378       * for example user searches for "php" and we offer him to add "php" to his interests.
1379       *
1380       * @param   string   $component component responsible for tagging. For BC it can be empty but in this case the
1381       *                   query will be slow because DB index will not be used.
1382       * @param   string   $itemtype    the record type to look for
1383       * @param   int      $itemid      the record id to look for
1384       * @param   string   $tagname     a tag name
1385       * @return  int                   1 if it is tagged, 0 otherwise
1386       */
1387      public static function is_item_tagged_with($component, $itemtype, $itemid, $tagname) {
1388          global $DB;
1389          $tagcollid = core_tag_area::get_collection($component, $itemtype);
1390          $query = 'SELECT 1 FROM {tag} t
1391                      JOIN {tag_instance} ti ON ti.tagid = t.id
1392                      WHERE t.name = ? AND t.tagcollid = ? AND ti.itemtype = ? AND ti.itemid = ?';
1393          $cleanname = core_text::strtolower(clean_param($tagname, PARAM_TAG));
1394          $params = array($cleanname, $tagcollid, $itemtype, $itemid);
1395          if ($component) {
1396              $query .= ' AND ti.component = ?';
1397              $params[] = $component;
1398          }
1399          return $DB->record_exists_sql($query, $params) ? 1 : 0;
1400      }
1401  
1402      /**
1403       * Returns whether the tag area is enabled
1404       *
1405       * @param string $component component responsible for tagging
1406       * @param string $itemtype what is being tagged, for example, 'post', 'course', 'user', etc.
1407       * @return bool|null
1408       */
1409      public static function is_enabled($component, $itemtype) {
1410          return core_tag_area::is_enabled($component, $itemtype);
1411      }
1412  
1413      /**
1414       * Retrieves contents of tag area for the tag/index.php page
1415       *
1416       * @param stdClass $tagarea
1417       * @param bool $exclusivemode if set to true it means that no other entities tagged with this tag
1418       *             are displayed on the page and the per-page limit may be bigger
1419       * @param int $fromctx context id where the link was displayed, may be used by callbacks
1420       *            to display items in the same context first
1421       * @param int $ctx context id where to search for records
1422       * @param bool $rec search in subcontexts as well
1423       * @param int $page 0-based number of page being displayed
1424       * @return \core_tag\output\tagindex
1425       */
1426      public function get_tag_index($tagarea, $exclusivemode, $fromctx, $ctx, $rec, $page = 0) {
1427          global $CFG;
1428          if (!empty($tagarea->callback)) {
1429              if (!empty($tagarea->callbackfile)) {
1430                  require_once($CFG->dirroot . '/' . ltrim($tagarea->callbackfile, '/'));
1431              }
1432              $callback = $tagarea->callback;
1433              return call_user_func_array($callback, [$this, $exclusivemode, $fromctx, $ctx, $rec, $page]);
1434          }
1435          return null;
1436      }
1437  
1438      /**
1439       * Returns formatted description of the tag
1440       *
1441       * @param array $options
1442       * @return string
1443       */
1444      public function get_formatted_description($options = array()) {
1445          $options = empty($options) ? array() : (array)$options;
1446          $options += array('para' => false, 'overflowdiv' => true);
1447          $description = file_rewrite_pluginfile_urls($this->description, 'pluginfile.php',
1448                  context_system::instance()->id, 'tag', 'description', $this->id);
1449          return format_text($description, $this->descriptionformat, $options);
1450      }
1451  
1452      /**
1453       * Returns the list of tag links available for the current user (edit, flag, etc.)
1454       *
1455       * @return array
1456       */
1457      public function get_links() {
1458          global $USER;
1459          $links = array();
1460  
1461          if (!isloggedin() || isguestuser()) {
1462              return $links;
1463          }
1464  
1465          $tagname = $this->get_display_name();
1466          $systemcontext = context_system::instance();
1467  
1468          // Add a link for users to add/remove this from their interests.
1469          if (static::is_enabled('core', 'user') && core_tag_area::get_collection('core', 'user') == $this->tagcollid) {
1470              if (static::is_item_tagged_with('core', 'user', $USER->id, $this->name)) {
1471                  $url = new moodle_url('/tag/user.php', array('action' => 'removeinterest',
1472                      'sesskey' => sesskey(), 'tag' => $this->rawname));
1473                  $links[] = html_writer::link($url, get_string('removetagfrommyinterests', 'tag', $tagname),
1474                          array('class' => 'removefrommyinterests'));
1475              } else {
1476                  $url = new moodle_url('/tag/user.php', array('action' => 'addinterest',
1477                      'sesskey' => sesskey(), 'tag' => $this->rawname));
1478                  $links[] = html_writer::link($url, get_string('addtagtomyinterests', 'tag', $tagname),
1479                          array('class' => 'addtomyinterests'));
1480              }
1481          }
1482  
1483          // Flag as inappropriate link.  Only people with moodle/tag:flag capability.
1484          if (has_capability('moodle/tag:flag', $systemcontext)) {
1485              $url = new moodle_url('/tag/user.php', array('action' => 'flaginappropriate',
1486                  'sesskey' => sesskey(), 'id' => $this->id));
1487              $links[] = html_writer::link($url, get_string('flagasinappropriate', 'tag', $tagname),
1488                          array('class' => 'flagasinappropriate'));
1489          }
1490  
1491          // Edit tag: Only people with moodle/tag:edit capability who either have it as an interest or can manage tags.
1492          if (has_capability('moodle/tag:edit', $systemcontext) ||
1493                  has_capability('moodle/tag:manage', $systemcontext)) {
1494              $url = new moodle_url('/tag/edit.php', array('id' => $this->id));
1495              $links[] = html_writer::link($url, get_string('edittag', 'tag'),
1496                          array('class' => 'edittag'));
1497          }
1498  
1499          return $links;
1500      }
1501  
1502      /**
1503       * Delete one or more tag, and all their instances if there are any left.
1504       *
1505       * @param    int|array    $tagids one tagid (int), or one array of tagids to delete
1506       * @return   bool     true on success, false otherwise
1507       */
1508      public static function delete_tags($tagids) {
1509          global $DB;
1510  
1511          if (!is_array($tagids)) {
1512              $tagids = array($tagids);
1513          }
1514          if (empty($tagids)) {
1515              return;
1516          }
1517  
1518          // Use the tagids to create a select statement to be used later.
1519          list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids);
1520  
1521          // Store the tags and tag instances we are going to delete.
1522          $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
1523          $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
1524  
1525          // Delete all the tag instances.
1526          $select = 'WHERE tagid ' . $tagsql;
1527          $sql = "DELETE FROM {tag_instance} $select";
1528          $DB->execute($sql, $tagparams);
1529  
1530          // Delete all the tag correlations.
1531          $sql = "DELETE FROM {tag_correlation} $select";
1532          $DB->execute($sql, $tagparams);
1533  
1534          // Delete all the tags.
1535          $select = 'WHERE id ' . $tagsql;
1536          $sql = "DELETE FROM {tag} $select";
1537          $DB->execute($sql, $tagparams);
1538  
1539          // Fire an event that these items were untagged.
1540          if ($taginstances) {
1541              // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
1542              $syscontextid = context_system::instance()->id;
1543              // Loop through the tag instances and fire a 'tag_removed'' event.
1544              foreach ($taginstances as $taginstance) {
1545                  // We can not fire an event with 'null' as the contextid.
1546                  if (is_null($taginstance->contextid)) {
1547                      $taginstance->contextid = $syscontextid;
1548                  }
1549  
1550                  // Trigger tag removed event.
1551                  \core\event\tag_removed::create_from_tag_instance($taginstance,
1552                      $tags[$taginstance->tagid]->name, $tags[$taginstance->tagid]->rawname,
1553                      true)->trigger();
1554              }
1555          }
1556  
1557          // Fire an event that these tags were deleted.
1558          if ($tags) {
1559              $context = context_system::instance();
1560              foreach ($tags as $tag) {
1561                  // Delete all files associated with this tag.
1562                  $fs = get_file_storage();
1563                  $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
1564                  foreach ($files as $file) {
1565                      $file->delete();
1566                  }
1567  
1568                  // Trigger an event for deleting this tag.
1569                  $event = \core\event\tag_deleted::create(array(
1570                      'objectid' => $tag->id,
1571                      'relateduserid' => $tag->userid,
1572                      'context' => $context,
1573                      'other' => array(
1574                          'name' => $tag->name,
1575                          'rawname' => $tag->rawname
1576                      )
1577                  ));
1578                  $event->add_record_snapshot('tag', $tag);
1579                  $event->trigger();
1580              }
1581          }
1582  
1583          return true;
1584      }
1585  
1586      /**
1587       * Combine together correlated tags of several tags
1588       *
1589       * This is a help method for method combine_tags()
1590       *
1591       * @param core_tag_tag[] $tags
1592       */
1593      protected function combine_correlated_tags($tags) {
1594          global $DB;
1595          $ids = array_map(function($t) {
1596              return $t->id;
1597          }, $tags);
1598  
1599          // Retrieve the correlated tags of this tag and correlated tags of all tags to be merged in one query
1600          // but store them separately. Calculate the list of correlated tags that need to be added to the current.
1601          list($sql, $params) = $DB->get_in_or_equal($ids);
1602          $params[] = $this->id;
1603          $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql.' OR tagid = ?',
1604              $params, '', 'tagid, id, correlatedtags');
1605          $correlated = array();
1606          $mycorrelated = array();
1607          foreach ($records as $record) {
1608              $taglist = preg_split('/\s*,\s*/', trim($record->correlatedtags), -1, PREG_SPLIT_NO_EMPTY);
1609              if ($record->tagid == $this->id) {
1610                  $mycorrelated = $taglist;
1611              } else {
1612                  $correlated = array_merge($correlated, $taglist);
1613              }
1614          }
1615          array_unique($correlated);
1616          // Strip out from $correlated the ids of the tags that are already in $mycorrelated
1617          // or are one of the tags that are going to be combined.
1618          $correlated = array_diff($correlated, [$this->id], $ids, $mycorrelated);
1619  
1620          if (empty($correlated)) {
1621              // Nothing to do, ignore situation when current tag is correlated to one of the merged tags - they will
1622              // be deleted later and get_tag_correlation() will not return them. Next cron will clean everything up.
1623              return;
1624          }
1625  
1626          // Update correlated tags of this tag.
1627          $newcorrelatedlist = join(',', array_merge($mycorrelated, $correlated));
1628          if (isset($records[$this->id])) {
1629              $DB->update_record('tag_correlation', array('id' => $records[$this->id]->id, 'correlatedtags' => $newcorrelatedlist));
1630          } else {
1631              $DB->insert_record('tag_correlation', array('tagid' => $this->id, 'correlatedtags' => $newcorrelatedlist));
1632          }
1633  
1634          // Add this tag to the list of correlated tags of each tag in $correlated.
1635          list($sql, $params) = $DB->get_in_or_equal($correlated);
1636          $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql, $params, '', 'tagid, id, correlatedtags');
1637          foreach ($correlated as $tagid) {
1638              if (isset($records[$tagid])) {
1639                  $newcorrelatedlist = $records[$tagid]->correlatedtags . ',' . $this->id;
1640                  $DB->update_record('tag_correlation', array('id' => $records[$tagid]->id, 'correlatedtags' => $newcorrelatedlist));
1641              } else {
1642                  $DB->insert_record('tag_correlation', array('tagid' => $tagid, 'correlatedtags' => '' . $this->id));
1643              }
1644          }
1645      }
1646  
1647      /**
1648       * Combines several other tags into this one
1649       *
1650       * Combining rules:
1651       * - current tag becomes the "main" one, all instances
1652       *   pointing to other tags are changed to point to it.
1653       * - if any of the tags is standard, the "main" tag becomes standard too
1654       * - all tags except for the current ("main") are deleted, even when they are standard
1655       *
1656       * @param core_tag_tag[] $tags tags to combine into this one
1657       */
1658      public function combine_tags($tags) {
1659          global $DB;
1660  
1661          $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags');
1662  
1663          // Retrieve all tag objects, find if there are any standard tags in the set.
1664          $isstandard = false;
1665          $tagstocombine = array();
1666          $ids = array();
1667          $relatedtags = $this->get_manual_related_tags();
1668          foreach ($tags as $tag) {
1669              $tag->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'tagcollid', 'name', 'rawname'), 'combine_tags');
1670              if ($tag && $tag->id != $this->id && $tag->tagcollid == $this->tagcollid) {
1671                  $isstandard = $isstandard || $tag->isstandard;
1672                  $tagstocombine[$tag->name] = $tag;
1673                  $ids[] = $tag->id;
1674                  $relatedtags = array_merge($relatedtags, $tag->get_manual_related_tags());
1675              }
1676          }
1677  
1678          if (empty($tagstocombine)) {
1679              // Nothing to do.
1680              return;
1681          }
1682  
1683          // Combine all manually set related tags, exclude itself all the tags it is about to be combined with.
1684          if ($relatedtags) {
1685              $relatedtags = array_map(function($t) {
1686                  return $t->name;
1687              }, $relatedtags);
1688              array_unique($relatedtags);
1689              $relatedtags = array_diff($relatedtags, [$this->name], array_keys($tagstocombine));
1690          }
1691          $this->set_related_tags($relatedtags);
1692  
1693          // Combine all correlated tags, exclude itself all the tags it is about to be combined with.
1694          $this->combine_correlated_tags($tagstocombine);
1695  
1696          // If any of the duplicate tags are standard, mark this one as standard too.
1697          if ($isstandard && !$this->isstandard) {
1698              $this->update(array('isstandard' => 1));
1699          }
1700  
1701          // Go through all instances of each tag that needs to be combined and make them point to this tag instead.
1702          // We go though the list one by one because otherwise looking-for-duplicates logic would be too complicated.
1703          foreach ($tagstocombine as $tag) {
1704              $params = array('tagid' => $tag->id, 'mainid' => $this->id);
1705              $mainsql = 'SELECT ti.*, t.name, t.rawname, tim.id AS alreadyhasmaintag '
1706                      . 'FROM {tag_instance} ti '
1707                      . 'LEFT JOIN {tag} t ON t.id = ti.tagid '
1708                      . 'LEFT JOIN {tag_instance} tim ON ti.component = tim.component AND '
1709                      . '    ti.itemtype = tim.itemtype AND ti.itemid = tim.itemid AND '
1710                      . '    ti.tiuserid = tim.tiuserid AND tim.tagid = :mainid '
1711                      . 'WHERE ti.tagid = :tagid';
1712  
1713              $records = $DB->get_records_sql($mainsql, $params);
1714              foreach ($records as $record) {
1715                  if ($record->alreadyhasmaintag) {
1716                      // Item is tagged with both main tag and the duplicate tag.
1717                      // Remove instance pointing to the duplicate tag.
1718                      $tag->delete_instance_as_record($record, false);
1719                      $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
1720                              WHERE itemtype = :itemtype
1721                          AND itemid = :itemid AND component = :component AND tiuserid = :tiuserid
1722                          AND ordering > :ordering";
1723                      $DB->execute($sql, (array)$record);
1724                  } else {
1725                      // Item is tagged only with duplicate tag but not the main tag.
1726                      // Replace tagid in the instance pointing to the duplicate tag with this tag.
1727                      $DB->update_record('tag_instance', array('id' => $record->id, 'tagid' => $this->id));
1728                      \core\event\tag_removed::create_from_tag_instance($record, $record->name, $record->rawname)->trigger();
1729                      $record->tagid = $this->id;
1730                      \core\event\tag_added::create_from_tag_instance($record, $this->name, $this->rawname)->trigger();
1731                  }
1732              }
1733          }
1734  
1735          // Finally delete all tags that we combined into the current one.
1736          self::delete_tags($ids);
1737      }
1738  
1739      /**
1740       * Retrieve a list of tags that have been used to tag the given $component
1741       * and $itemtype in the provided $contexts.
1742       *
1743       * @param string $component The tag instance component
1744       * @param string $itemtype The tag instance item type
1745       * @param context[] $contexts The list of contexts to look for tag instances in
1746       * @return core_tag_tag[]
1747       */
1748      public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) {
1749          global $DB;
1750  
1751          $params = [$component, $itemtype];
1752          $contextids = array_map(function($context) {
1753              return $context->id;
1754          }, $contexts);
1755          list($contextsql, $contextsqlparams) = $DB->get_in_or_equal($contextids);
1756          $params = array_merge($params, $contextsqlparams);
1757  
1758          $subsql = "SELECT DISTINCT t.id
1759                      FROM {tag} t
1760                      JOIN {tag_instance} ti ON t.id = ti.tagid
1761                     WHERE component = ?
1762                     AND itemtype = ?
1763                     AND contextid {$contextsql}";
1764  
1765          $sql = "SELECT tt.*
1766                  FROM ($subsql) tv
1767                  JOIN {tag} tt ON tt.id = tv.id";
1768  
1769          return array_map(function($record) {
1770              return new core_tag_tag($record);
1771          }, $DB->get_records_sql($sql, $params));
1772      }
1773  }