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 * 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 */ 24 25 namespace mod_forum\local\vaults; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 use mod_forum\local\entities\forum as forum_entity; 30 use mod_forum\local\entities\post as post_entity; 31 use mod_forum\local\factories\entity as entity_factory; 32 use stdClass; 33 34 /** 35 * Post vault class. 36 * 37 * This should be the only place that accessed the database. 38 * 39 * This class should not return any objects other than post_entity objects. The class 40 * may contain some utility count methods which return integers. 41 * 42 * This uses the repository pattern. See: 43 * https://designpatternsphp.readthedocs.io/en/latest/More/Repository/README.html 44 * 45 * @copyright 2019 Ryan Wyllie <ryan@moodle.com> 46 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 47 */ 48 class post extends db_table_vault { 49 /** The table for this vault */ 50 private const TABLE = 'forum_posts'; 51 /** Alias for user id */ 52 private const USER_ID_ALIAS = 'userpictureid'; 53 /** Alias for user fields */ 54 private const USER_ALIAS = 'userrecord'; 55 56 /** 57 * Get the table alias. 58 * 59 * @return string 60 */ 61 protected function get_table_alias() : string { 62 return 'p'; 63 } 64 65 /** 66 * Build the SQL to be used in get_records_sql. 67 * 68 * @param string|null $wheresql Where conditions for the SQL 69 * @param string|null $sortsql Order by conditions for the SQL 70 * @param int|null $userid The user ID 71 * @return string 72 */ 73 protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, ?int $userid = null) : string { 74 $table = self::TABLE; 75 $alias = $this->get_table_alias(); 76 $fields = $alias . '.*'; 77 $tables = "{{$table}} {$alias}"; 78 79 $selectsql = "SELECT {$fields} FROM {$tables}"; 80 $selectsql .= $wheresql ? ' WHERE ' . $wheresql : ''; 81 $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : ''; 82 83 return $selectsql; 84 } 85 86 /** 87 * Convert the DB records into post entities. 88 * 89 * @param array $results The DB records 90 * @return post_entity[] 91 */ 92 protected function from_db_records(array $results) { 93 $entityfactory = $this->get_entity_factory(); 94 95 return array_map(function(array $result) use ($entityfactory) { 96 ['record' => $record] = $result; 97 return $entityfactory->get_post_from_stdclass($record); 98 }, $results); 99 } 100 101 /** 102 * Get the post ids for the given discussion. 103 * 104 * @param stdClass $user The user to check the unread count for 105 * @param int $discussionid The discussion to load posts for 106 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 107 * @param string $orderby Order the results 108 * @return post_entity[] 109 */ 110 public function get_from_discussion_id( 111 stdClass $user, 112 int $discussionid, 113 bool $canseeprivatereplies, 114 string $orderby = 'created ASC' 115 ) : array { 116 return $this->get_from_discussion_ids($user, [$discussionid], $canseeprivatereplies, $orderby); 117 } 118 119 /** 120 * Get the list of posts for the given discussions. 121 * 122 * @param stdClass $user The user to load posts for. 123 * @param int[] $discussionids The list of discussion ids to load posts for 124 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 125 * @param string $orderby Order the results 126 * @return post_entity[] 127 */ 128 public function get_from_discussion_ids( 129 stdClass $user, 130 array $discussionids, 131 bool $canseeprivatereplies, 132 string $orderby = '' 133 ) : array { 134 if (empty($discussionids)) { 135 return []; 136 } 137 138 return $this->get_from_filters($user, ['discussionids' => $discussionids], $canseeprivatereplies, $orderby); 139 } 140 141 /** 142 * The method returns posts based on a set of filters. 143 * 144 * @param stdClass $user Only used when restricting private replies 145 * @param array $filters Export filters, valid filters are: 146 * 147 * 'discussionids' => array of discussion ids eg [1,2,3] 148 * 'userids' => array of user ids eg [1,2,3] 149 * 'from' => timestamp to filter posts from this date. 150 * 'to' => timestamp to filter posts till this date. 151 * 152 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 153 * @param string $orderby Order the results 154 * @return post_entity[] 155 */ 156 public function get_from_filters( 157 stdClass $user, 158 array $filters, 159 bool $canseeprivatereplies, 160 string $orderby = '' 161 ): array { 162 if (count($filters) == 0) { 163 return []; 164 } 165 $wheresql = []; 166 $params = []; 167 $alias = $this->get_table_alias(); 168 169 // Filter by discussion ids. 170 if (!empty($filters['discussionids'])) { 171 list($indiscussionssql, $indiscussionsparams) = $this->get_db()->get_in_or_equal($filters['discussionids'], 172 SQL_PARAMS_NAMED); 173 $wheresql[] = "{$alias}.discussion {$indiscussionssql}"; 174 $params += $indiscussionsparams; 175 } 176 177 // Filter by user ids. 178 if (!empty($filters['userids'])) { 179 list($inuserssql, $inusersparams) = $this->get_db()->get_in_or_equal($filters['userids'], 180 SQL_PARAMS_NAMED); 181 $wheresql[] = "{$alias}.userid {$inuserssql}"; 182 $params += $inusersparams; 183 } 184 185 // Filter posts by from and to dates. 186 if (isset($filters['from'])) { 187 $wheresql[] = "{$alias}.created >= :from"; 188 $params['from'] = $filters['from']; 189 } 190 191 if (isset($filters['to'])) { 192 $wheresql[] = "{$alias}.created < :to"; 193 $params['to'] = $filters['to']; 194 } 195 196 // We need to build the WHERE here, because get_private_reply_sql returns the query with the AND clause. 197 $wheresql = implode(' AND ', $wheresql); 198 199 // Build private replies sql. 200 [ 201 'where' => $privatewhere, 202 'params' => $privateparams, 203 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 204 $wheresql .= "{$privatewhere}"; 205 $params += $privateparams; 206 207 if ($orderby) { 208 $orderbysql = $alias . '.' . $orderby; 209 } else { 210 $orderbysql = ''; 211 } 212 213 $sql = $this->generate_get_records_sql($wheresql, $orderbysql); 214 $records = $this->get_db()->get_records_sql($sql, $params); 215 216 return $this->transform_db_records_to_entities($records); 217 } 218 219 /** 220 * Load a list of replies to the given post. This will load all descendants of the post. 221 * That is, all direct replies and replies to those replies etc. 222 * 223 * The return value will be a flat array of posts in the requested order. 224 * 225 * @param stdClass $user The user to check the unread count for 226 * @param post_entity $post The post to load replies for 227 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 228 * @param string $orderby How to order the replies 229 * @return post_entity[] 230 */ 231 public function get_replies_to_post( 232 stdClass $user, 233 post_entity $post, 234 bool $canseeprivatereplies, 235 string $orderby = 'created ASC' 236 ) : array { 237 $alias = $this->get_table_alias(); 238 239 [ 240 'where' => $privatewhere, 241 'params' => $privateparams, 242 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 243 244 $params = array_merge([ 245 'discussionid' => $post->get_discussion_id(), 246 'created' => $post->get_time_created(), 247 'excludepostid' => $post->get_id(), 248 ], $privateparams); 249 250 // Unfortunately the best we can do to filter down the query is ignore all posts 251 // that were created before the given post (since they can't be replies). 252 // We also filter to remove private replies if the user cannot vie them. 253 $wheresql = "{$alias}.discussion = :discussionid 254 AND {$alias}.created >= :created {$privatewhere} 255 AND {$alias}.id != :excludepostid"; 256 $orderbysql = $alias . '.' . $orderby; 257 $sql = $this->generate_get_records_sql($wheresql, $orderbysql); 258 $records = $this->get_db()->get_records_sql($sql, $params); 259 $posts = $this->transform_db_records_to_entities($records); 260 $sorter = $this->get_entity_factory()->get_posts_sorter(); 261 262 // We need to sort all of the values into the replies tree in order to capture 263 // the full list of descendants. 264 $sortedposts = $sorter->sort_into_children($posts); 265 $replies = []; 266 267 // From the sorted list we can grab the first elements and check if they are replies 268 // to the post we care about. If so we keep them. 269 foreach ($sortedposts as $candidate) { 270 [$candidatepost, $candidatereplies] = $candidate; 271 if ($candidatepost->has_parent() && $candidatepost->get_parent_id() == $post->get_id()) { 272 $replies[] = $candidate; 273 } 274 } 275 276 if (empty($replies)) { 277 return $replies; 278 } 279 280 $getreplypostids = function($candidates) use (&$getreplypostids) { 281 $ids = []; 282 283 foreach ($candidates as $candidate) { 284 [$reply, $replies] = $candidate; 285 $ids = array_merge($ids, [$reply->get_id()], $getreplypostids($replies)); 286 } 287 288 return $ids; 289 }; 290 // Recursively build a list of the ids of all posts in the full reply tree. 291 $replypostids = $getreplypostids($replies); 292 293 // Now go back and filter the original result set down to just the posts that 294 // we've flagged as in the reply tree. We need to filter the original set of values 295 // so that we can maintain the requested sort order. 296 return array_values(array_filter($posts, function($post) use ($replypostids) { 297 return in_array($post->get_id(), $replypostids); 298 })); 299 } 300 301 /** 302 * Get a mapping of replies to the specified discussions. 303 * 304 * @param stdClass $user The user to check the unread count for 305 * @param int[] $discussionids The list of discussions to fetch counts for 306 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 307 * @return int[] The number of replies for each discussion returned in an associative array 308 */ 309 public function get_reply_count_for_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { 310 if (empty($discussionids)) { 311 return []; 312 } 313 314 list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); 315 316 [ 317 'where' => $privatewhere, 318 'params' => $privateparams, 319 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 320 321 $sql = "SELECT discussion, COUNT(1) 322 FROM {" . self::TABLE . "} p 323 WHERE p.discussion {$insql} AND p.parent > 0 {$privatewhere} 324 GROUP BY discussion"; 325 326 return $this->get_db()->get_records_sql_menu($sql, array_merge($params, $privateparams)); 327 } 328 329 /** 330 * Get a mapping of replies to the specified discussions. 331 * 332 * @param stdClass $user The user to check the unread count for 333 * @param int $postid The post to collect replies to 334 * @param int $discussionid The list of discussions to fetch counts for 335 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 336 * @return int The number of replies for each discussion returned in an associative array 337 */ 338 public function get_reply_count_for_post_id_in_discussion_id( 339 stdClass $user, int $postid, int $discussionid, bool $canseeprivatereplies) : int { 340 [ 341 'where' => $privatewhere, 342 'params' => $privateparams, 343 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 344 345 $alias = $this->get_table_alias(); 346 $table = self::TABLE; 347 348 $sql = "SELECT {$alias}.id, {$alias}.parent 349 FROM {{$table}} {$alias} 350 WHERE p.discussion = :discussionid {$privatewhere}"; 351 352 $postparents = $this->get_db()->get_records_sql_menu($sql, array_merge([ 353 'discussionid' => $discussionid, 354 ], $privateparams)); 355 356 return $this->count_children_from_parent_recursively($postparents, $postid); 357 } 358 359 /** 360 * Count the children whose parent matches the current record recursively. 361 * 362 * @param array $postparents The full mapping of posts. 363 * @param int $postid The ID to check for 364 * @return int $count 365 */ 366 private function count_children_from_parent_recursively(array $postparents, int $postid) : int { 367 if (!isset($postparents[$postid])) { 368 // Post not found at all. 369 return 0; 370 } 371 372 $count = 0; 373 foreach ($postparents as $pid => $parentid) { 374 if ($postid == $parentid) { 375 $count += $this->count_children_from_parent_recursively($postparents, $pid) + 1; 376 } 377 } 378 379 return $count; 380 } 381 382 /** 383 * Get a mapping of unread post counts for the specified discussions. 384 * 385 * @param stdClass $user The user to fetch counts for 386 * @param int[] $discussionids The list of discussions to fetch counts for 387 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 388 * @return int[] The count of unread posts for each discussion returned in an associative array 389 */ 390 public function get_unread_count_for_discussion_ids(stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { 391 global $CFG; 392 393 if (empty($discussionids)) { 394 return []; 395 } 396 397 [ 398 'where' => $privatewhere, 399 'params' => $privateparams, 400 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 401 402 $alias = $this->get_table_alias(); 403 list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); 404 $sql = "SELECT p.discussion, COUNT(p.id) FROM {" . self::TABLE . "} p 405 LEFT JOIN {forum_read} r ON r.postid = p.id AND r.userid = :userid 406 WHERE p.discussion {$insql} AND p.modified > :cutofftime AND r.id IS NULL {$privatewhere} 407 GROUP BY p.discussion"; 408 409 $params['userid'] = $user->id; 410 $params['cutofftime'] = floor((new \DateTime()) 411 ->sub(new \DateInterval("P{$CFG->forum_oldpostdays}D")) 412 ->format('U') / 60) * 60; 413 414 return $this->get_db()->get_records_sql_menu($sql, array_merge($params, $privateparams)); 415 } 416 417 /** 418 * Get a mapping of the most recent post record in each discussion based on post creation time. 419 * 420 * @param stdClass $user 421 * @param array $discussionids 422 * @param bool $canseeprivatereplies 423 * @return array 424 * @throws \coding_exception 425 * @throws \dml_exception 426 */ 427 public function get_latest_posts_for_discussion_ids( 428 stdClass $user, array $discussionids, bool $canseeprivatereplies) : array { 429 430 if (empty($discussionids)) { 431 return []; 432 } 433 434 list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); 435 436 [ 437 'where' => $privatewhere, 438 'params' => $privateparams, 439 ] = $this->get_private_reply_sql($user, $canseeprivatereplies, "mp"); 440 441 $sql = " 442 SELECT posts.* 443 FROM {" . self::TABLE . "} posts 444 JOIN ( 445 SELECT p.discussion, MAX(p.id) as latestpostid 446 FROM {" . self::TABLE . "} p 447 JOIN ( 448 SELECT mp.discussion, MAX(mp.created) AS created 449 FROM {" . self::TABLE . "} mp 450 WHERE mp.discussion {$insql} {$privatewhere} 451 GROUP BY mp.discussion 452 ) lp ON lp.discussion = p.discussion AND lp.created = p.created 453 GROUP BY p.discussion 454 ) plp on plp.discussion = posts.discussion AND plp.latestpostid = posts.id"; 455 456 $records = $this->get_db()->get_records_sql($sql, array_merge($params, $privateparams)); 457 $entities = $this->transform_db_records_to_entities($records); 458 459 return array_reduce($entities, function($carry, $entity) { 460 $carry[$entity->get_discussion_id()] = $entity; 461 return $carry; 462 }, []); 463 } 464 465 /** 466 * Get the SQL where and additional parameters to use to restrict posts to private reply posts. 467 * 468 * @param stdClass $user The user to fetch counts for 469 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 470 * @return array The SQL WHERE clause, and parameters to use in the SQL. 471 */ 472 private function get_private_reply_sql(stdClass $user, bool $canseeprivatereplies, $posttablealias = "p") { 473 $params = []; 474 $privatewhere = ''; 475 if (!$canseeprivatereplies) { 476 $privatewhere = " AND ({$posttablealias}.privatereplyto = :privatereplyto OR " . 477 "{$posttablealias}.userid = :privatereplyfrom OR {$posttablealias}.privatereplyto = 0)"; 478 $params['privatereplyto'] = $user->id; 479 $params['privatereplyfrom'] = $user->id; 480 } 481 482 return [ 483 'where' => $privatewhere, 484 'params' => $params, 485 ]; 486 } 487 488 /** 489 * Get a mapping of the first post in each discussion based on post creation time. 490 * 491 * @param int[] $discussionids The list of discussions to fetch counts for 492 * @return post_entity[] The post object of the first post for each discussions returned in an associative array 493 */ 494 public function get_first_post_for_discussion_ids(array $discussionids) : array { 495 496 if (empty($discussionids)) { 497 return []; 498 } 499 500 list($insql, $params) = $this->get_db()->get_in_or_equal($discussionids, SQL_PARAMS_NAMED); 501 502 $sql = " 503 SELECT p.* 504 FROM {" . self::TABLE . "} p 505 JOIN ( 506 SELECT mp.discussion, MIN(mp.created) AS created 507 FROM {" . self::TABLE . "} mp 508 WHERE mp.discussion {$insql} 509 GROUP BY mp.discussion 510 ) lp ON lp.discussion = p.discussion AND lp.created = p.created"; 511 512 $records = $this->get_db()->get_records_sql($sql, $params); 513 return $this->transform_db_records_to_entities($records); 514 } 515 516 /** 517 * Get the posts for the given user. 518 * 519 * @param int $discussionid The discussion to fetch posts for 520 * @param int $userid The user to fetch posts for 521 * @param bool $canseeprivatereplies Whether this user can see all private replies or not 522 * @param string $orderby Order the results 523 * @return post_entity[] 524 */ 525 public function get_posts_in_discussion_for_user_id( 526 int $discussionid, 527 int $userid, 528 bool $canseeprivatereplies, 529 string $orderby = 'created ASC' 530 ): array { 531 $user = $this->get_db()->get_record('user', ['id' => (int)$userid], '*', IGNORE_MISSING); 532 533 $alias = $this->get_table_alias(); 534 [ 535 'where' => $privatewhere, 536 'params' => $privateparams, 537 ] = $this->get_private_reply_sql($user, $canseeprivatereplies); 538 539 $wheresql = "{$alias}.userid = :authorid AND 540 {$alias}.discussion = :discussionid {$privatewhere}"; 541 $orderbysql = $alias . '.' . $orderby; 542 543 $sql = $this->generate_get_records_sql($wheresql, $orderbysql); 544 $records = $this->get_db()->get_records_sql($sql, array_merge([ 545 'authorid' => $userid, 546 'discussionid' => $discussionid 547 ], $privateparams)); 548 549 return $this->transform_db_records_to_entities($records); 550 } 551 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body