MDL-64820 forum: fix up issues from review
[moodle.git] / mod / forum / classes / local / vaults / post.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  * Post vault class.
19  *
20  * @package    mod_forum
21  * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
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\entities\post as post_entity;
30 use mod_forum\local\factories\entity as entity_factory;
31 use stdClass;
33 /**
34  * Post vault class.
35  *
36  * This should be the only place that accessed the database.
37  *
38  * This uses the repository pattern. See:
39  * https://designpatternsphp.readthedocs.io/en/latest/More/Repository/README.html
40  *
41  * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
42  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  */
44 class post extends db_table_vault {
45     /** The table for this vault */
46     private const TABLE = 'forum_posts';
47     /** Alias for user id */
48     private const USER_ID_ALIAS = 'userpictureid';
49     /** Alias for user fields */
50     private const USER_ALIAS = 'userrecord';
52     /**
53      * Get the table alias.
54      *
55      * @return string
56      */
57     protected function get_table_alias() : string {
58         return 'p';
59     }
61     /**
62      * Build the SQL to be used in get_records_sql.
63      *
64      * @param string|null $wheresql Where conditions for the SQL
65      * @param string|null $sortsql Order by conditions for the SQL
66      * @return string
67      */
68     protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
69         $table = self::TABLE;
70         $alias = $this->get_table_alias();
71         $fields = $alias . '.*';
72         $tables = "{{$table}} {$alias}";
74         $selectsql = "SELECT {$fields} FROM {$tables}";
75         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
76         $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
78         return $selectsql;
79     }
81     /**
82      * Convert the DB records into post entities.
83      *
84      * @param array $results The DB records
85      * @return post_entity[]
86      */
87     protected function from_db_records(array $results) {
88         $entityfactory = $this->get_entity_factory();
90         return array_map(function(array $result) use ($entityfactory) {
91             ['record' => $record] = $result;
92             return $entityfactory->get_post_from_stdclass($record);
93         }, $results);
94     }
96     /**
97      * Get the post ids for the given discussion.
98      *
99      * @param int $discussionid The discussion to load posts for
100      * @param string $orderby Order the results
101      * @return post_entity[]
102      */
103     public function get_from_discussion_id(int $discussionid, string $orderby = 'created ASC') : array {
104         $alias = $this->get_table_alias();
105         $wheresql = $alias . '.discussion = ?';
106         $orderbysql = $alias . '.' . $orderby;
107         $sql = $this->generate_get_records_sql($wheresql, $orderbysql);
108         $records = $this->get_db()->get_records_sql($sql, [$discussionid]);
110         return $this->transform_db_records_to_entities($records);
111     }
113     /**
114      * Get the list of posts for the given discussions.
115      *
116      * @param int[] $discussionids The list of discussion ids to load posts for
117      * @return post_entity[]
118      */
119     public function get_from_discussion_ids(array $discussionids) : array {
120         if (empty($discussionids)) {
121             return [];
122         }
124         $alias = $this->get_table_alias();
126         list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids);
128         $wheresql = "{$alias}.discussion {$insql}";
130         $sql = $this->generate_get_records_sql($wheresql, '');
131         $records = $this->get_db()->get_records_sql($sql, $params);
133         return $this->transform_db_records_to_entities($records);
134     }
136     /**
137      * Load a list of replies to the given post. This will load all descendants of the post.
138      * That is, all direct replies and replies to those replies etc.
139      *
140      * The return value will be a flat array of posts in the requested order.
141      *
142      * @param post_entity $post The post to load replies for
143      * @param string $orderby How to order the replies
144      * @return post_entity[]
145      */
146     public function get_replies_to_post(post_entity $post, string $orderby = 'created ASC') : array {
147         $alias = $this->get_table_alias();
148         $params = [$post->get_discussion_id(), $post->get_time_created(), $post->get_id()];
149         // Unfortunately the best we can do to filter down the query is ignore all posts
150         // that were created before the given post (since they can't be replies).
151         $wheresql = "{$alias}.discussion = ? and {$alias}.created >= ? and {$alias}.id != ?";
152         $orderbysql = $alias . '.' . $orderby;
153         $sql = $this->generate_get_records_sql($wheresql, $orderbysql);
154         $records = $this->get_db()->get_records_sql($sql, $params);
155         $posts = $this->transform_db_records_to_entities($records);
156         $sorter = $this->get_entity_factory()->get_posts_sorter();
157         // We need to sort all of the values into the replies tree in order to capture
158         // the full list of descendants.
159         $sortedposts = $sorter->sort_into_children($posts);
160         $replies = [];
162         // From the sorted list we can grab the first elements and check if they are replies
163         // to the post we care about. If so we keep them.
164         foreach ($sortedposts as $candidate) {
165             [$candidatepost, $candidatereplies] = $candidate;
166             if ($candidatepost->has_parent() && $candidatepost->get_parent_id() == $post->get_id()) {
167                 $replies[] = $candidate;
168             }
169         }
171         if (empty($replies)) {
172             return $replies;
173         }
175         $getreplypostids = function($candidates) use (&$getreplypostids) {
176             $ids = [];
178             foreach ($candidates as $candidate) {
179                 [$reply, $replies] = $candidate;
180                 $ids = array_merge($ids, [$reply->get_id()], $getreplypostids($replies));
181             }
183             return $ids;
184         };
185         // Recursively build a list of the ids of all posts in the full reply tree.
186         $replypostids = $getreplypostids($replies);
188         // Now go back and filter the original result set down to just the posts that
189         // we've flagged as in the reply tree. We need to filter the original set of values
190         // so that we can maintain the requested sort order.
191         return array_values(array_filter($posts, function($post) use ($replypostids) {
192             return in_array($post->get_id(), $replypostids);
193         }));
194     }
196     /**
197      * Get a mapping of replies to the specified discussions.
198      *
199      * @param   int[]       $discussionids The list of discussions to fetch counts for
200      * @return  int[]       The number of replies for each discussion returned in an associative array
201      */
202     public function get_reply_count_for_discussion_ids(array $discussionids) : array {
203         if (empty($discussionids)) {
204             return [];
205         }
206         list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids);
207         $sql = "SELECT discussion, COUNT(1) FROM {" . self::TABLE . "} p " .
208             "WHERE p.discussion {$insql} AND p.parent > 0 GROUP BY discussion";
210         return $this->get_db()->get_records_sql_menu($sql, $params);
211     }
213     /**
214      * Get a mapping of unread post counts for the specified discussions.
215      *
216      * @param   stdClass    $user The user to fetch counts for
217      * @param   int[]       $discussionids The list of discussions to fetch counts for
218      * @return  int[]       The count of unread posts for each discussion returned in an associative array
219      */
220     public function get_unread_count_for_discussion_ids(stdClass $user, array $discussionids) : array {
221         global $CFG;
223         if (empty($discussionids)) {
224             return [];
225         }
227         $alias = $this->get_table_alias();
228         list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED);
229         $sql = "SELECT p.discussion, COUNT(p.id) FROM {" . self::TABLE . "} p
230              LEFT JOIN {forum_read} r ON r.postid = p.id AND r.userid = :userid
231                  WHERE p.discussion {$insql} AND p.modified > :cutofftime AND r.id IS NULL
232               GROUP BY p.discussion";
234         $params['userid'] = $user->id;
235         $params['cutofftime'] = floor((new \DateTime())
236             ->sub(new \DateInterval("P{$CFG->forum_oldpostdays}D"))
237             ->format('U') / 60) * 60;
239         return $this->get_db()->get_records_sql_menu($sql, $params);
240     }
242     /**
243      * Get a mapping of the most recent post in each discussion based on post creation time.
244      *
245      * @param   int[]       $discussionids The list of discussions to fetch counts for
246      * @return  int[]       The post id of the most recent post for each discussions returned in an associative array
247      */
248     public function get_latest_post_id_for_discussion_ids(array $discussionids) : array {
249         global $CFG;
251         if (empty($discussionids)) {
252             return [];
253         }
255         $alias = $this->get_table_alias();
256         list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED);
258         $sql = "
259             SELECT p.discussion, MAX(p.id)
260               FROM {" . self::TABLE . "} p
261               JOIN (
262                 SELECT mp.discussion, MAX(mp.created) AS created
263                   FROM {" . self::TABLE . "} mp
264                  WHERE mp.discussion {$insql}
265               GROUP BY mp.discussion
266               ) lp ON lp.discussion = p.discussion AND lp.created = p.created
267           GROUP BY p.discussion";
269         return $this->get_db()->get_records_sql_menu($sql, $params);
270     }