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