Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 39 and 311]

   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   * Vault class for a discussion list.
  19   *
  20   * @package    mod_forum
  21   * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace mod_forum\local\vaults;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use core_group\output\group_details;
  30  use mod_forum\local\vaults\preprocessors\extract_record as extract_record_preprocessor;
  31  use mod_forum\local\vaults\preprocessors\extract_user as extract_user_preprocessor;
  32  use mod_forum\local\renderers\discussion_list as discussion_list_renderer;
  33  use core\dml\table as dml_table;
  34  use stdClass;
  35  
  36  /**
  37   * Discussion list vault.
  38   *
  39   * This should be the only place that accessed the database.
  40   *
  41   * This uses the repository pattern. See:
  42   * https://designpatternsphp.readthedocs.io/en/latest/More/Repository/README.html
  43   *
  44   * @package    mod_forum
  45   * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
  46   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  47   */
  48  class discussion_list extends db_table_vault {
  49      /** The table for this vault */
  50      private const TABLE = 'forum_discussions';
  51      /** Alias for first author id */
  52      private const FIRST_AUTHOR_ID_ALIAS = 'fauserpictureid';
  53      /** Alias for author fields */
  54      private const FIRST_AUTHOR_ALIAS = 'fauserrecord';
  55      /** Alias for last author id */
  56      private const LATEST_AUTHOR_ID_ALIAS = 'lauserpictureid';
  57      /** Alias for last author fields */
  58      private const LATEST_AUTHOR_ALIAS = 'lauserrecord';
  59      /** Default limit */
  60      public const PAGESIZE_DEFAULT = 100;
  61  
  62      /** Sort by newest first */
  63      public const SORTORDER_LASTPOST_DESC = 1;
  64      /** Sort by oldest first */
  65      public const SORTORDER_LASTPOST_ASC = 2;
  66      /** Sort by created desc */
  67      public const SORTORDER_CREATED_DESC = 3;
  68      /** Sort by created asc */
  69      public const SORTORDER_CREATED_ASC = 4;
  70      /** Sort by number of replies desc */
  71      public const SORTORDER_REPLIES_DESC = 5;
  72      /** Sort by number of replies desc */
  73      public const SORTORDER_REPLIES_ASC = 6;
  74      /** Sort by discussion name desc */
  75      public const SORTORDER_DISCUSSION_DESC = 7;
  76      /** Sort by discussion name asc */
  77      public const SORTORDER_DISCUSSION_ASC = 8;
  78      /** Sort by discussion starter's name desc */
  79      public const SORTORDER_STARTER_DESC = 9;
  80      /** Sort by discussion starter's name asc */
  81      public const SORTORDER_STARTER_ASC = 10;
  82      /** Sort by group name desc */
  83      public const SORTORDER_GROUP_DESC = 11;
  84      /** Sort by group name asc */
  85      public const SORTORDER_GROUP_ASC = 12;
  86  
  87      /**
  88       * Get the table alias.
  89       *
  90       * @return string
  91       */
  92      protected function get_table_alias() : string {
  93          return 'd';
  94      }
  95  
  96      /**
  97       * Get the favourite table alias
  98       *
  99       * @return string
 100       */
 101      protected function get_favourite_alias() : string {
 102          return 'favalias';
 103      }
 104  
 105      /**
 106       * Build the SQL to be used in get_records_sql.
 107       *
 108       * @param string|null $wheresql Where conditions for the SQL
 109       * @param string|null $sortsql Order by conditions for the SQL
 110       * @param int|null $userid The ID of the user we are performing this query for
 111       *
 112       * @return string
 113       */
 114      protected function generate_get_records_sql(string $wheresql = null, ?string $sortsql = null, ?int $userid = null) : string {
 115          $alias = $this->get_table_alias();
 116  
 117          $includefavourites = $userid ? true : false;
 118  
 119          $favsql = '';
 120          if ($includefavourites) {
 121              list($favsql, $favparams) = $this->get_favourite_sql($userid);
 122              foreach ($favparams as $key => $param) {
 123                  $favsql = str_replace(":$key", "'$param'", $favsql);
 124              }
 125          }
 126  
 127          // Fetch:
 128          // - Discussion
 129          // - First post
 130          // - Author
 131          // - Most recent editor.
 132          $thistable = new dml_table(self::TABLE, $alias, $alias);
 133          $posttable = new dml_table('forum_posts', 'fp', 'p_');
 134          $userfieldsapi = \core_user\fields::for_userpic()->including('deleted');
 135          $firstauthorfields = $userfieldsapi->get_sql('fa', false,
 136                  self::FIRST_AUTHOR_ALIAS, self::FIRST_AUTHOR_ID_ALIAS, false)->selects;
 137          $latestuserfields = $userfieldsapi->get_sql('la', false,
 138                  self::LATEST_AUTHOR_ALIAS, self::LATEST_AUTHOR_ID_ALIAS, false)->selects;
 139  
 140          $fields = implode(', ', [
 141              $thistable->get_field_select(),
 142              $posttable->get_field_select(),
 143              $firstauthorfields,
 144              $latestuserfields,
 145          ]);
 146  
 147          $sortkeys = [
 148              $this->get_sort_order(self::SORTORDER_REPLIES_DESC, $includefavourites),
 149              $this->get_sort_order(self::SORTORDER_REPLIES_ASC, $includefavourites)
 150          ];
 151          $issortbyreplies = in_array($sortsql, $sortkeys);
 152  
 153          $tables = $thistable->get_from_sql();
 154          $tables .= ' JOIN ' . $posttable->get_from_sql() . ' ON fp.id = ' . $alias . '.firstpost';
 155          $tables .= ' JOIN {user} fa ON fa.id = fp.userid';
 156          $tables .= ' JOIN {user} la ON la.id = ' . $alias . '.usermodified';
 157          $tables .= $favsql;
 158  
 159          if ($issortbyreplies) {
 160              // Join the discussion replies.
 161              $tables .= ' JOIN (
 162                              SELECT rd.id, COUNT(rp.id) as replycount
 163                              FROM {forum_discussions} rd
 164                              LEFT JOIN {forum_posts} rp
 165                                  ON rp.discussion = rd.id AND rp.id != rd.firstpost
 166                              GROUP BY rd.id
 167                           ) r ON d.id = r.id';
 168          }
 169  
 170          $groupsortorders = [
 171              $this->get_sort_order(self::SORTORDER_GROUP_DESC, $includefavourites),
 172              $this->get_sort_order(self::SORTORDER_GROUP_ASC, $includefavourites)
 173          ];
 174          $sortbygroup = in_array($sortsql, $groupsortorders);
 175          if ($sortbygroup) {
 176              $groupstable = new dml_table('groups', 'g', 'g');
 177              $fields .= ', ' . $groupstable->get_field_select();
 178              // Join groups.
 179              $tables .= 'LEFT JOIN {groups} g ON g.id = d.groupid';
 180          }
 181  
 182          $selectsql = 'SELECT ' . $fields . ' FROM ' . $tables;
 183          $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
 184          $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
 185  
 186          return $selectsql;
 187      }
 188  
 189      /**
 190       * Build the SQL to be used in count_records_sql.
 191       *
 192       * @param string|null $wheresql Where conditions for the SQL
 193       * @return string
 194       */
 195      protected function generate_count_records_sql(string $wheresql = null) : string {
 196          $alias = $this->get_table_alias();
 197          $db = $this->get_db();
 198  
 199          $selectsql = "SELECT COUNT(1) FROM {" . self::TABLE . "} {$alias}";
 200          $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
 201  
 202          return $selectsql;
 203      }
 204  
 205      /**
 206       * Get a list of preprocessors to execute on the DB results before being converted
 207       * into entities.
 208       *
 209       * @return array
 210       */
 211      protected function get_preprocessors() : array {
 212          return array_merge(
 213              parent::get_preprocessors(),
 214              [
 215                  'discussion' => new extract_record_preprocessor(self::TABLE, $this->get_table_alias()),
 216                  'firstpost' => new extract_record_preprocessor('forum_posts', 'p_'),
 217                  'firstpostauthor' => new extract_user_preprocessor(self::FIRST_AUTHOR_ID_ALIAS, self::FIRST_AUTHOR_ALIAS),
 218                  'latestpostauthor' => new extract_user_preprocessor(self::LATEST_AUTHOR_ID_ALIAS, self::LATEST_AUTHOR_ALIAS),
 219              ]
 220          );
 221      }
 222  
 223      /**
 224       * Convert the DB records into discussion list entities.
 225       *
 226       * @param array $results The DB records
 227       * @return discussion_list[]
 228       */
 229      protected function from_db_records(array $results) {
 230          $entityfactory = $this->get_entity_factory();
 231  
 232          return array_map(function(array $result) use ($entityfactory) {
 233              [
 234                  'discussion' => $discussion,
 235                  'firstpost' => $firstpost,
 236                  'firstpostauthor' => $firstpostauthor,
 237                  'latestpostauthor' => $latestpostauthor,
 238              ] = $result;
 239              return $entityfactory->get_discussion_summary_from_stdclass(
 240                  $discussion,
 241                  $firstpost,
 242                  $firstpostauthor,
 243                  $latestpostauthor
 244              );
 245          }, $results);
 246      }
 247  
 248      /**
 249       * Get the field to sort by.
 250       *
 251       * @param int|null $sortmethod
 252       * @return string
 253       */
 254      protected function get_keyfield(?int $sortmethod) : string {
 255          global $CFG;
 256  
 257          switch ($sortmethod) {
 258              case self::SORTORDER_CREATED_DESC:
 259              case self::SORTORDER_CREATED_ASC:
 260                  return 'fp.created';
 261              case self::SORTORDER_REPLIES_DESC:
 262              case self::SORTORDER_REPLIES_ASC:
 263                  return 'replycount';
 264              case self::SORTORDER_DISCUSSION_DESC:
 265              case self::SORTORDER_DISCUSSION_ASC:
 266                  return 'dname';
 267              case self::SORTORDER_STARTER_DESC:
 268              case self::SORTORDER_STARTER_ASC:
 269                  // We'll sort by the first name field of the discussion starter's name.
 270  
 271                  // Let's get the full name display config first.
 272                  $nameformat = $CFG->fullnamedisplay;
 273                  if ($CFG->fullnamedisplay === 'language') {
 274                      $nameformat = get_string('fullnamedisplay', '', (object)['firstname' => 'firstname', 'lastname' => 'lastname']);
 275                  }
 276                  // Fetch all the available user name fields.
 277                  $availablefields = order_in_string(\core_user\fields::get_name_fields(), $nameformat);
 278                  // We'll default to the first name if there's no available name field.
 279                  $returnfield = 'firstname';
 280                  if (!empty($availablefields)) {
 281                      // Use the first name field.
 282                      $returnfield = reset($availablefields);
 283                  }
 284                  return 'fauserrecord' . $returnfield;
 285              case self::SORTORDER_GROUP_DESC:
 286              case self::SORTORDER_GROUP_ASC:
 287                  return 'gname';
 288              default:
 289                  global $CFG;
 290                  $alias = $this->get_table_alias();
 291                  $field = "{$alias}.timemodified";
 292                  if (!empty($CFG->forum_enabletimedposts)) {
 293                      return "CASE WHEN {$field} < {$alias}.timestart THEN {$alias}.timestart ELSE {$field} END";
 294                  }
 295                  return $field;
 296          }
 297      }
 298  
 299      /**
 300       * Get the sort direction.
 301       *
 302       * @param int|null $sortmethod
 303       * @return string
 304       */
 305      protected function get_sort_direction(?int $sortmethod) : string {
 306          switch ($sortmethod) {
 307              case self::SORTORDER_LASTPOST_ASC:
 308              case self::SORTORDER_CREATED_ASC:
 309              case self::SORTORDER_REPLIES_ASC:
 310              case self::SORTORDER_DISCUSSION_ASC:
 311              case self::SORTORDER_STARTER_ASC:
 312              case self::SORTORDER_GROUP_ASC:
 313                  return "ASC";
 314              case self::SORTORDER_LASTPOST_DESC:
 315              case self::SORTORDER_CREATED_DESC:
 316              case self::SORTORDER_REPLIES_DESC:
 317              case self::SORTORDER_DISCUSSION_DESC:
 318              case self::SORTORDER_STARTER_DESC:
 319              case self::SORTORDER_GROUP_DESC:
 320              default:
 321                  return "DESC";
 322          }
 323      }
 324  
 325      /**
 326       * Get the sort order SQL for a sort method.
 327       *
 328       * @param int|null  $sortmethod
 329       * @param bool|null $includefavourites
 330       * @return string
 331       */
 332      private function get_sort_order(?int $sortmethod, bool $includefavourites = true) : string {
 333  
 334          $alias = $this->get_table_alias();
 335          // TODO consider user favourites...
 336          $keyfield = $this->get_keyfield($sortmethod);
 337          $direction = $this->get_sort_direction($sortmethod);
 338  
 339          $favouritesort = '';
 340          if ($includefavourites) {
 341              $favalias = $this->get_favourite_alias();
 342              // Since we're joining on the favourite table any discussion that isn't favourited will have
 343              // null in the favourite columns. Nulls behave differently in the sorting for different databases.
 344              // We can ensure consistency between databases by explicitly deprioritising any null favourite field
 345              // using a case statement.
 346              $favouritesort = ", CASE WHEN {$favalias}.id IS NULL THEN 0 ELSE 1 END DESC";
 347              // After the null favourite fields are deprioritised and appear below the favourited discussions we
 348              // need to order the favourited discussions by id so that the most recently favourited discussions
 349              // appear at the top of the list.
 350              $favouritesort .= ", {$favalias}.itemtype DESC";
 351          }
 352  
 353          return "{$alias}.pinned DESC $favouritesort , {$keyfield} {$direction}, {$alias}.id {$direction}";
 354      }
 355  
 356      /**
 357       * Fetch any required SQL to respect timed posts.
 358       *
 359       * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
 360       * @param   int|null    $includepostsforuser Which user to include posts for, if any
 361       * @return  array       The SQL and parameters to include
 362       */
 363      protected function get_hidden_post_sql(bool $includehiddendiscussions, ?int $includepostsforuser) {
 364          $wheresql = '';
 365          $params = [];
 366          if (!$includehiddendiscussions) {
 367              $now = time();
 368              $wheresql = " AND ((d.timestart <= :timestart AND (d.timeend = 0 OR d.timeend > :timeend))";
 369              $params['timestart'] = $now;
 370              $params['timeend'] = $now;
 371              if (null !== $includepostsforuser) {
 372                  $wheresql .= " OR d.userid = :byuser";
 373                  $params['byuser'] = $includepostsforuser;
 374              }
 375              $wheresql .= ")";
 376          }
 377  
 378          return [
 379              'wheresql' => $wheresql,
 380              'params' => $params,
 381          ];
 382      }
 383  
 384      /**
 385       * Get each discussion, first post, first and last post author for the given forum, considering timed posts, and
 386       * pagination.
 387       *
 388       * @param   int         $forumid The forum to fetch the discussion set for
 389       * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
 390       * @param   int|null    $includepostsforuser Which user to include posts for, if any
 391       * @param   int         $sortorder The sort order to use
 392       * @param   int         $limit The number of discussions to fetch
 393       * @param   int         $offset The record offset
 394       * @return  array       The set of data fetched
 395       */
 396      public function get_from_forum_id(
 397          int $forumid,
 398          bool $includehiddendiscussions,
 399          ?int $includepostsforuser,
 400          ?int $sortorder,
 401          int $limit,
 402          int $offset
 403      ) {
 404          $alias = $this->get_table_alias();
 405          $wheresql = "{$alias}.forum = :forumid";
 406          [
 407              'wheresql' => $hiddensql,
 408              'params' => $hiddenparams
 409          ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
 410          $wheresql .= $hiddensql;
 411  
 412          $params = array_merge($hiddenparams, [
 413              'forumid' => $forumid,
 414          ]);
 415  
 416          $includefavourites = $includepostsforuser ? true : false;
 417          $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, $includefavourites),
 418              $includepostsforuser);
 419          $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
 420  
 421          return $this->transform_db_records_to_entities($records);
 422      }
 423  
 424      /**
 425       * Get each discussion, first post, first and last post author for the given forum, and the set of groups to display
 426       * considering timed posts, and pagination.
 427       *
 428       * @param   int         $forumid The forum to fetch the discussion set for
 429       * @param   int[]       $groupids The list of real groups to filter on
 430       * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
 431       * @param   int|null    $includepostsforuser Which user to include posts for, if any
 432       * @param   int         $sortorder The sort order to use
 433       * @param   int         $limit The number of discussions to fetch
 434       * @param   int         $offset The record offset
 435       * @return  array       The set of data fetched
 436       */
 437      public function get_from_forum_id_and_group_id(
 438          int $forumid,
 439          array $groupids,
 440          bool $includehiddendiscussions,
 441          ?int $includepostsforuser,
 442          ?int $sortorder,
 443          int $limit,
 444          int $offset
 445      ) {
 446          $alias = $this->get_table_alias();
 447  
 448          $wheresql = "{$alias}.forum = :forumid AND ";
 449          $groupparams = [];
 450          if (empty($groupids)) {
 451              $wheresql .= "{$alias}.groupid = :allgroupsid";
 452          } else {
 453              list($insql, $groupparams) = $this->get_db()->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gid');
 454              $wheresql .= "({$alias}.groupid = :allgroupsid OR {$alias}.groupid {$insql})";
 455          }
 456  
 457          [
 458              'wheresql' => $hiddensql,
 459              'params' => $hiddenparams
 460          ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
 461          $wheresql .= $hiddensql;
 462  
 463          $params = array_merge($hiddenparams, $groupparams, [
 464              'forumid' => $forumid,
 465              'allgroupsid' => -1,
 466          ]);
 467  
 468          $includefavourites = $includepostsforuser ? true : false;
 469          $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, $includefavourites),
 470              $includepostsforuser);
 471          $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
 472  
 473          return $this->transform_db_records_to_entities($records);
 474      }
 475  
 476      /**
 477       * Count the number of discussions in the forum.
 478       *
 479       * @param int $forumid Id of the forum to count discussions in
 480       * @param bool $includehiddendiscussions Include hidden dicussions in the count?
 481       * @param int|null $includepostsforuser Include discussions created by this user in the count
 482       *                                      (only works if not including hidden discussions).
 483       * @return int
 484       */
 485      public function get_total_discussion_count_from_forum_id(
 486          int $forumid,
 487          bool $includehiddendiscussions,
 488          ?int $includepostsforuser
 489      ) {
 490          $alias = $this->get_table_alias();
 491  
 492          $wheresql = "{$alias}.forum = :forumid";
 493  
 494          [
 495              'wheresql' => $hiddensql,
 496              'params' => $hiddenparams
 497          ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
 498          $wheresql .= $hiddensql;
 499  
 500          $params = array_merge($hiddenparams, [
 501              'forumid' => $forumid,
 502          ]);
 503  
 504          return $this->get_db()->count_records_sql($this->generate_count_records_sql($wheresql), $params);
 505      }
 506  
 507      /**
 508       * Count the number of discussions in all groups and the list of groups provided.
 509       *
 510       * @param int $forumid Id of the forum to count discussions in
 511       * @param int[] $groupids List of group ids to include in the count (discussions in all groups will always be counted)
 512       * @param bool $includehiddendiscussions Include hidden dicussions in the count?
 513       * @param int|null $includepostsforuser Include discussions created by this user in the count
 514       *                                      (only works if not including hidden discussions).
 515       * @return int
 516       */
 517      public function get_total_discussion_count_from_forum_id_and_group_id(
 518          int $forumid,
 519          array $groupids,
 520          bool $includehiddendiscussions,
 521          ?int $includepostsforuser
 522      ) {
 523          $alias = $this->get_table_alias();
 524  
 525          $wheresql = "{$alias}.forum = :forumid AND ";
 526          $groupparams = [];
 527          if (empty($groupids)) {
 528              $wheresql .= "{$alias}.groupid = :allgroupsid";
 529          } else {
 530              list($insql, $groupparams) = $this->get_db()->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gid');
 531              $wheresql .= "({$alias}.groupid = :allgroupsid OR {$alias}.groupid {$insql})";
 532          }
 533  
 534          [
 535              'wheresql' => $hiddensql,
 536              'params' => $hiddenparams
 537          ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
 538          $wheresql .= $hiddensql;
 539  
 540          $params = array_merge($hiddenparams, $groupparams, [
 541              'forumid' => $forumid,
 542              'allgroupsid' => -1,
 543          ]);
 544  
 545          return $this->get_db()->count_records_sql($this->generate_count_records_sql($wheresql), $params);
 546      }
 547  
 548      /**
 549       * Get the standard favouriting sql.
 550       *
 551       * @param int $userid The ID of the user we are getting the sql for
 552       * @return [$sql, $params] An array comprising of the sql and any associated params
 553       */
 554      private function get_favourite_sql(int $userid): array {
 555  
 556          $usercontext = \context_user::instance($userid);
 557          $alias = $this->get_table_alias();
 558          $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
 559          list($favsql, $favparams) = $ufservice->get_join_sql_by_type('mod_forum', 'discussions',
 560              $this->get_favourite_alias(), "$alias.id");
 561  
 562          return [$favsql, $favparams];
 563      }
 564  }