MDL-66481 mod_forum: Implement additional column sorting
[moodle.git] / mod / forum / classes / local / vaults / discussion_list.php
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/>.
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  */
25 namespace mod_forum\local\vaults;
27 defined('MOODLE_INTERNAL') || die();
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;
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;
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;
87     /**
88      * Get the table alias.
89      *
90      * @return string
91      */
92     protected function get_table_alias() : string {
93         return 'd';
94     }
96     /**
97      * Get the favourite table alias
98      *
99      * @return string
100      */
101     protected function get_favourite_alias() : string {
102         return 'favalias';
103     }
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();
117         $includefavourites = $userid ? true : false;
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         }
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         $firstauthorfields = \user_picture::fields('fa', ['deleted'], self::FIRST_AUTHOR_ID_ALIAS, self::FIRST_AUTHOR_ALIAS);
135         $latestuserfields = \user_picture::fields('la', ['deleted'], self::LATEST_AUTHOR_ID_ALIAS, self::LATEST_AUTHOR_ALIAS);
137         $fields = implode(', ', [
138             $thistable->get_field_select(),
139             $posttable->get_field_select(),
140             $firstauthorfields,
141             $latestuserfields,
142         ]);
144         $sortkeys = [
145             $this->get_sort_order(self::SORTORDER_REPLIES_DESC, $includefavourites),
146             $this->get_sort_order(self::SORTORDER_REPLIES_ASC, $includefavourites)
147         ];
148         $issortbyreplies = in_array($sortsql, $sortkeys);
150         $tables = $thistable->get_from_sql();
151         $tables .= ' JOIN ' . $posttable->get_from_sql() . ' ON fp.id = ' . $alias . '.firstpost';
152         $tables .= ' JOIN {user} fa ON fa.id = fp.userid';
153         $tables .= ' JOIN {user} la ON la.id = ' . $alias . '.usermodified';
154         $tables .= $favsql;
156         if ($issortbyreplies) {
157             // Join the discussion replies.
158             $tables .= ' JOIN (
159                             SELECT rd.id, COUNT(rp.id) as replycount
160                             FROM {forum_discussions} rd
161                             LEFT JOIN {forum_posts} rp
162                                 ON rp.discussion = rd.id AND rp.id != rd.firstpost
163                             GROUP BY rd.id
164                          ) r ON d.id = r.id';
165         }
167         $groupsortorders = [
168             $this->get_sort_order(self::SORTORDER_GROUP_DESC, $includefavourites),
169             $this->get_sort_order(self::SORTORDER_GROUP_ASC, $includefavourites)
170         ];
171         $sortbygroup = in_array($sortsql, $groupsortorders);
172         if ($sortbygroup) {
173             $groupstable = new dml_table('groups', 'g', 'g');
174             $fields .= ', ' . $groupstable->get_field_select();
175             // Join groups.
176             $tables .= 'LEFT JOIN {groups} g ON g.id = d.groupid';
177         }
179         $selectsql = 'SELECT ' . $fields . ' FROM ' . $tables;
180         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
181         $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
183         return $selectsql;
184     }
186     /**
187      * Build the SQL to be used in count_records_sql.
188      *
189      * @param string|null $wheresql Where conditions for the SQL
190      * @return string
191      */
192     protected function generate_count_records_sql(string $wheresql = null) : string {
193         $alias = $this->get_table_alias();
194         $db = $this->get_db();
196         $selectsql = "SELECT COUNT(1) FROM {" . self::TABLE . "} {$alias}";
197         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
199         return $selectsql;
200     }
202     /**
203      * Get a list of preprocessors to execute on the DB results before being converted
204      * into entities.
205      *
206      * @return array
207      */
208     protected function get_preprocessors() : array {
209         return array_merge(
210             parent::get_preprocessors(),
211             [
212                 'discussion' => new extract_record_preprocessor(self::TABLE, $this->get_table_alias()),
213                 'firstpost' => new extract_record_preprocessor('forum_posts', 'p_'),
214                 'firstpostauthor' => new extract_user_preprocessor(self::FIRST_AUTHOR_ID_ALIAS, self::FIRST_AUTHOR_ALIAS),
215                 'latestpostauthor' => new extract_user_preprocessor(self::LATEST_AUTHOR_ID_ALIAS, self::LATEST_AUTHOR_ALIAS),
216             ]
217         );
218     }
220     /**
221      * Convert the DB records into discussion list entities.
222      *
223      * @param array $results The DB records
224      * @return discussion_list[]
225      */
226     protected function from_db_records(array $results) {
227         $entityfactory = $this->get_entity_factory();
229         return array_map(function(array $result) use ($entityfactory) {
230             [
231                 'discussion' => $discussion,
232                 'firstpost' => $firstpost,
233                 'firstpostauthor' => $firstpostauthor,
234                 'latestpostauthor' => $latestpostauthor,
235             ] = $result;
236             return $entityfactory->get_discussion_summary_from_stdclass(
237                 $discussion,
238                 $firstpost,
239                 $firstpostauthor,
240                 $latestpostauthor
241             );
242         }, $results);
243     }
245     /**
246      * Get the field to sort by.
247      *
248      * @param int|null $sortmethod
249      * @return string
250      */
251     protected function get_keyfield(?int $sortmethod) : string {
252         global $CFG;
254         switch ($sortmethod) {
255             case self::SORTORDER_CREATED_DESC:
256             case self::SORTORDER_CREATED_ASC:
257                 return 'fp.created';
258             case self::SORTORDER_REPLIES_DESC:
259             case self::SORTORDER_REPLIES_ASC:
260                 return 'replycount';
261             case self::SORTORDER_DISCUSSION_DESC:
262             case self::SORTORDER_DISCUSSION_ASC:
263                 return 'dname';
264             case self::SORTORDER_STARTER_DESC:
265             case self::SORTORDER_STARTER_ASC:
266                 // We'll sort by the first name field of the discussion starter's name.
268                 // Let's get the full name display config first.
269                 $nameformat = $CFG->fullnamedisplay;
270                 if ($CFG->fullnamedisplay === 'language') {
271                     $nameformat = get_string('fullnamedisplay', '', (object)['firstname' => 'firstname', 'lastname' => 'lastname']);
272                 }
273                 // Fetch all the available user name fields.
274                 $availablefields = order_in_string(get_all_user_name_fields(), $nameformat);
275                 // We'll default to the first name if there's no available name field.
276                 $returnfield = 'firstname';
277                 if (!empty($availablefields)) {
278                     // Use the first name field.
279                     $returnfield = reset($availablefields);
280                 }
281                 return 'fauserrecord' . $returnfield;
282             case self::SORTORDER_GROUP_DESC:
283             case self::SORTORDER_GROUP_ASC:
284                 return 'gname';
285             default:
286                 global $CFG;
287                 $alias = $this->get_table_alias();
288                 $field = "{$alias}.timemodified";
289                 if (!empty($CFG->forum_enabletimedposts)) {
290                     return "CASE WHEN {$field} < {$alias}.timestart THEN {$alias}.timestart ELSE {$field} END";
291                 }
292                 return $field;
293         }
294     }
296     /**
297      * Get the sort direction.
298      *
299      * @param int|null $sortmethod
300      * @return string
301      */
302     protected function get_sort_direction(?int $sortmethod) : string {
303         switch ($sortmethod) {
304             case self::SORTORDER_LASTPOST_ASC:
305             case self::SORTORDER_CREATED_ASC:
306             case self::SORTORDER_REPLIES_ASC:
307             case self::SORTORDER_DISCUSSION_ASC:
308             case self::SORTORDER_STARTER_ASC:
309             case self::SORTORDER_GROUP_ASC:
310                 return "ASC";
311             case self::SORTORDER_LASTPOST_DESC:
312             case self::SORTORDER_CREATED_DESC:
313             case self::SORTORDER_REPLIES_DESC:
314             case self::SORTORDER_DISCUSSION_DESC:
315             case self::SORTORDER_STARTER_DESC:
316             case self::SORTORDER_GROUP_DESC:
317             default:
318                 return "DESC";
319         }
320     }
322     /**
323      * Get the sort order SQL for a sort method.
324      *
325      * @param int|null  $sortmethod
326      * @param bool|null $includefavourites
327      * @return string
328      */
329     private function get_sort_order(?int $sortmethod, bool $includefavourites = true) : string {
331         $alias = $this->get_table_alias();
332         // TODO consider user favourites...
333         $keyfield = $this->get_keyfield($sortmethod);
334         $direction = $this->get_sort_direction($sortmethod);
336         $favouritesort = '';
337         if ($includefavourites) {
338             $favalias = $this->get_favourite_alias();
339             // Since we're joining on the favourite table any discussion that isn't favourited will have
340             // null in the favourite columns. Nulls behave differently in the sorting for different databases.
341             // We can ensure consistency between databases by explicitly deprioritising any null favourite field
342             // using a case statement.
343             $favouritesort = ", CASE WHEN {$favalias}.id IS NULL THEN 0 ELSE 1 END DESC";
344             // After the null favourite fields are deprioritised and appear below the favourited discussions we
345             // need to order the favourited discussions by id so that the most recently favourited discussions
346             // appear at the top of the list.
347             $favouritesort .= ", {$favalias}.itemtype DESC";
348         }
350         return "{$alias}.pinned DESC $favouritesort , {$keyfield} {$direction}";
351     }
353     /**
354      * Fetch any required SQL to respect timed posts.
355      *
356      * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
357      * @param   int|null    $includepostsforuser Which user to include posts for, if any
358      * @return  array       The SQL and parameters to include
359      */
360     protected function get_hidden_post_sql(bool $includehiddendiscussions, ?int $includepostsforuser) {
361         $wheresql = '';
362         $params = [];
363         if (!$includehiddendiscussions) {
364             $now = time();
365             $wheresql = " AND ((d.timestart <= :timestart AND (d.timeend = 0 OR d.timeend > :timeend))";
366             $params['timestart'] = $now;
367             $params['timeend'] = $now;
368             if (null !== $includepostsforuser) {
369                 $wheresql .= " OR d.userid = :byuser";
370                 $params['byuser'] = $includepostsforuser;
371             }
372             $wheresql .= ")";
373         }
375         return [
376             'wheresql' => $wheresql,
377             'params' => $params,
378         ];
379     }
381     /**
382      * Get each discussion, first post, first and last post author for the given forum, considering timed posts, and
383      * pagination.
384      *
385      * @param   int         $forumid The forum to fetch the discussion set for
386      * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
387      * @param   int|null    $includepostsforuser Which user to include posts for, if any
388      * @param   int         $sortorder The sort order to use
389      * @param   int         $limit The number of discussions to fetch
390      * @param   int         $offset The record offset
391      * @return  array       The set of data fetched
392      */
393     public function get_from_forum_id(
394         int $forumid,
395         bool $includehiddendiscussions,
396         ?int $includepostsforuser,
397         ?int $sortorder,
398         int $limit,
399         int $offset
400     ) {
401         $alias = $this->get_table_alias();
402         $wheresql = "{$alias}.forum = :forumid";
403         [
404             'wheresql' => $hiddensql,
405             'params' => $hiddenparams
406         ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
407         $wheresql .= $hiddensql;
409         $params = array_merge($hiddenparams, [
410             'forumid' => $forumid,
411         ]);
413         $includefavourites = $includepostsforuser ? true : false;
414         $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, $includefavourites),
415             $includepostsforuser);
416         $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
418         return $this->transform_db_records_to_entities($records);
419     }
421     /**
422      * Get each discussion, first post, first and last post author for the given forum, and the set of groups to display
423      * considering timed posts, and pagination.
424      *
425      * @param   int         $forumid The forum to fetch the discussion set for
426      * @param   int[]       $groupids The list of real groups to filter on
427      * @param   bool        $includehiddendiscussions Whether to include hidden discussions or not
428      * @param   int|null    $includepostsforuser Which user to include posts for, if any
429      * @param   int         $sortorder The sort order to use
430      * @param   int         $limit The number of discussions to fetch
431      * @param   int         $offset The record offset
432      * @return  array       The set of data fetched
433      */
434     public function get_from_forum_id_and_group_id(
435         int $forumid,
436         array $groupids,
437         bool $includehiddendiscussions,
438         ?int $includepostsforuser,
439         ?int $sortorder,
440         int $limit,
441         int $offset
442     ) {
443         $alias = $this->get_table_alias();
445         $wheresql = "{$alias}.forum = :forumid AND ";
446         $groupparams = [];
447         if (empty($groupids)) {
448             $wheresql .= "{$alias}.groupid = :allgroupsid";
449         } else {
450             list($insql, $groupparams) = $this->get_db()->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gid');
451             $wheresql .= "({$alias}.groupid = :allgroupsid OR {$alias}.groupid {$insql})";
452         }
454         [
455             'wheresql' => $hiddensql,
456             'params' => $hiddenparams
457         ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
458         $wheresql .= $hiddensql;
460         $params = array_merge($hiddenparams, $groupparams, [
461             'forumid' => $forumid,
462             'allgroupsid' => -1,
463         ]);
465         $includefavourites = $includepostsforuser ? true : false;
466         $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, $includefavourites),
467             $includepostsforuser);
468         $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
470         return $this->transform_db_records_to_entities($records);
471     }
473     /**
474      * Count the number of discussions in the forum.
475      *
476      * @param int $forumid Id of the forum to count discussions in
477      * @param bool $includehiddendiscussions Include hidden dicussions in the count?
478      * @param int|null $includepostsforuser Include discussions created by this user in the count
479      *                                      (only works if not including hidden discussions).
480      * @return int
481      */
482     public function get_total_discussion_count_from_forum_id(
483         int $forumid,
484         bool $includehiddendiscussions,
485         ?int $includepostsforuser
486     ) {
487         $alias = $this->get_table_alias();
489         $wheresql = "{$alias}.forum = :forumid";
491         [
492             'wheresql' => $hiddensql,
493             'params' => $hiddenparams
494         ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
495         $wheresql .= $hiddensql;
497         $params = array_merge($hiddenparams, [
498             'forumid' => $forumid,
499         ]);
501         return $this->get_db()->count_records_sql($this->generate_count_records_sql($wheresql), $params);
502     }
504     /**
505      * Count the number of discussions in all groups and the list of groups provided.
506      *
507      * @param int $forumid Id of the forum to count discussions in
508      * @param int[] $groupids List of group ids to include in the count (discussions in all groups will always be counted)
509      * @param bool $includehiddendiscussions Include hidden dicussions in the count?
510      * @param int|null $includepostsforuser Include discussions created by this user in the count
511      *                                      (only works if not including hidden discussions).
512      * @return int
513      */
514     public function get_total_discussion_count_from_forum_id_and_group_id(
515         int $forumid,
516         array $groupids,
517         bool $includehiddendiscussions,
518         ?int $includepostsforuser
519     ) {
520         $alias = $this->get_table_alias();
522         $wheresql = "{$alias}.forum = :forumid AND ";
523         $groupparams = [];
524         if (empty($groupids)) {
525             $wheresql .= "{$alias}.groupid = :allgroupsid";
526         } else {
527             list($insql, $groupparams) = $this->get_db()->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gid');
528             $wheresql .= "({$alias}.groupid = :allgroupsid OR {$alias}.groupid {$insql})";
529         }
531         [
532             'wheresql' => $hiddensql,
533             'params' => $hiddenparams
534         ] = $this->get_hidden_post_sql($includehiddendiscussions, $includepostsforuser);
535         $wheresql .= $hiddensql;
537         $params = array_merge($hiddenparams, $groupparams, [
538             'forumid' => $forumid,
539             'allgroupsid' => -1,
540         ]);
542         return $this->get_db()->count_records_sql($this->generate_count_records_sql($wheresql), $params);
543     }
545     /**
546      * Get the standard favouriting sql.
547      *
548      * @param int $userid The ID of the user we are getting the sql for
549      * @return [$sql, $params] An array comprising of the sql and any associated params
550      */
551     private function get_favourite_sql(int $userid): array {
553         $usercontext = \context_user::instance($userid);
554         $alias = $this->get_table_alias();
555         $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
556         list($favsql, $favparams) = $ufservice->get_join_sql_by_type('mod_forum', 'discussions',
557             $this->get_favourite_alias(), "$alias.id");
559         return [$favsql, $favparams];
560     }