Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 156 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
| AbstractQuery | |
0.00% |
0 / 156 |
|
0.00% |
0 / 13 |
2756 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| loadMetadataBatch | |
0.00% |
0 / 70 |
|
0.00% |
0 / 1 |
272 | |||
| buildResult | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
| isFirstReply | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| isLastReply | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| getWorkflow | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| needsPreviousRevision | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| getPreviousRevision | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| getCurrentRevision | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| getRootPost | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| getRootPostId | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| getWorkflowById | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| getCurrentRevisionCacheKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Flow\Formatter; |
| 4 | |
| 5 | use Flow\Data\ManagerGroup; |
| 6 | use Flow\Exception\FlowException; |
| 7 | use Flow\Model\AbstractRevision; |
| 8 | use Flow\Model\Header; |
| 9 | use Flow\Model\PostRevision; |
| 10 | use Flow\Model\PostSummary; |
| 11 | use Flow\Model\UUID; |
| 12 | use Flow\Model\Workflow; |
| 13 | use Flow\Repository\TreeRepository; |
| 14 | use InvalidArgumentException; |
| 15 | use RuntimeException; |
| 16 | use Wikimedia\Rdbms\IResultWrapper; |
| 17 | |
| 18 | /** |
| 19 | * Base class that collects the data necessary to utilize AbstractFormatter |
| 20 | * based on a list of revisions. In some cases formatters will not utilize |
| 21 | * this query and will instead receive data from a table such as the core |
| 22 | * recentchanges. |
| 23 | */ |
| 24 | abstract class AbstractQuery { |
| 25 | /** |
| 26 | * @var ManagerGroup |
| 27 | */ |
| 28 | protected $storage; |
| 29 | |
| 30 | /** |
| 31 | * @var TreeRepository |
| 32 | */ |
| 33 | protected $treeRepository; |
| 34 | |
| 35 | // Consider converting these in-process caches to MapCacheLRU to avoid |
| 36 | // memory leaks. Should only be an issue if a batch is repeatedly doing queries. |
| 37 | /** |
| 38 | * @var UUID[] Associative array of post ID to root post's UUID object. |
| 39 | */ |
| 40 | protected $rootPostIdCache = []; |
| 41 | |
| 42 | /** |
| 43 | * @var PostRevision[] Associative array of post ID to PostRevision object. |
| 44 | */ |
| 45 | protected $postCache = []; |
| 46 | |
| 47 | /** |
| 48 | * @var AbstractRevision[] Associative array of revision ID to AbstractRevision object |
| 49 | */ |
| 50 | protected $revisionCache = []; |
| 51 | |
| 52 | /** |
| 53 | * @var Workflow[] Associative array of workflow ID to Workflow object. |
| 54 | */ |
| 55 | protected $workflowCache = []; |
| 56 | |
| 57 | /** |
| 58 | * Array of collection ids mapping to their most recent revision ids. |
| 59 | * |
| 60 | * @var UUID[] |
| 61 | */ |
| 62 | protected $currentRevisionsCache = []; |
| 63 | |
| 64 | /** @var array */ |
| 65 | protected $identityMap = []; |
| 66 | |
| 67 | public function __construct( ManagerGroup $storage, TreeRepository $treeRepository ) { |
| 68 | $this->storage = $storage; |
| 69 | $this->treeRepository = $treeRepository; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Entry point for batch loading metadata for a variety of revisions |
| 74 | * into the internal cache. |
| 75 | * |
| 76 | * @param AbstractRevision[]|IResultWrapper $results |
| 77 | */ |
| 78 | protected function loadMetadataBatch( $results ) { |
| 79 | // Batch load data related to a list of revisions |
| 80 | $postIds = []; |
| 81 | $workflowIds = []; |
| 82 | $revisions = []; |
| 83 | $previousRevisionIds = []; |
| 84 | $collectionIds = []; |
| 85 | foreach ( $results as $result ) { |
| 86 | if ( $result instanceof PostRevision ) { |
| 87 | // If top-level, then just get the workflow. |
| 88 | // Otherwise we need to find the root post. |
| 89 | $id = $result->getPostId(); |
| 90 | $alpha = $id->getAlphadecimal(); |
| 91 | if ( $result->isTopicTitle() ) { |
| 92 | $workflowIds[] = $id; |
| 93 | } else { |
| 94 | $postIds[$alpha] = $id; |
| 95 | } |
| 96 | $this->postCache[$alpha] = $result; |
| 97 | } elseif ( $result instanceof Header ) { |
| 98 | $workflowIds[] = $result->getWorkflowId(); |
| 99 | } elseif ( $result instanceof PostSummary ) { |
| 100 | // This would be the post id for the summary |
| 101 | $id = $result->getSummaryTargetId(); |
| 102 | $postIds[$id->getAlphadecimal()] = $id; |
| 103 | } |
| 104 | |
| 105 | $revisions[$result->getRevisionId()->getAlphadecimal()] = $result; |
| 106 | if ( $this->needsPreviousRevision( $result ) ) { |
| 107 | $previousRevisionIds[get_class( $result )][] = $result->getPrevRevisionId(); |
| 108 | } |
| 109 | |
| 110 | $collection = $result->getCollection(); |
| 111 | $collectionIds[get_class( $result )][] = $collection->getId(); |
| 112 | } |
| 113 | |
| 114 | // map from post Id to the related root post id |
| 115 | $rootPostIds = array_filter( $this->treeRepository->findRoots( $postIds ) ); |
| 116 | $rootPostRequests = []; |
| 117 | foreach ( $rootPostIds as $postId ) { |
| 118 | $rootPostRequests[] = [ 'rev_type_id' => $postId ]; |
| 119 | } |
| 120 | |
| 121 | // these tree identity maps are required for determining where a reply goes when |
| 122 | // replying to a specific post. |
| 123 | $identityMap = $this->treeRepository->fetchSubtreeIdentityMap( |
| 124 | array_unique( $rootPostIds, SORT_REGULAR ) |
| 125 | ); |
| 126 | |
| 127 | $rootPostResult = $this->storage->findMulti( |
| 128 | 'PostRevision', |
| 129 | $rootPostRequests, |
| 130 | [ |
| 131 | 'SORT' => 'rev_id', |
| 132 | 'ORDER' => 'DESC', |
| 133 | 'LIMIT' => 1, |
| 134 | ] |
| 135 | ); |
| 136 | |
| 137 | $rootPosts = []; |
| 138 | if ( count( $rootPostResult ) > 0 ) { |
| 139 | foreach ( $rootPostResult as $found ) { |
| 140 | $root = reset( $found ); |
| 141 | $rootPosts[$root->getPostId()->getAlphadecimal()] = $root; |
| 142 | $revisions[$root->getRevisionId()->getAlphadecimal()] = $root; |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Workflow IDs are the same as root post IDs |
| 147 | // So any post IDs that *are* root posts + found root post IDs + header workflow IDs |
| 148 | // should cover the lot. |
| 149 | $workflows = $this->storage->getMulti( 'Workflow', array_merge( $rootPostIds, $workflowIds ) ); |
| 150 | $workflows = $workflows ?: []; |
| 151 | |
| 152 | // preload all requested previous revisions |
| 153 | foreach ( $previousRevisionIds as $revisionType => $ids ) { |
| 154 | // get rid of null-values (for original revisions, without previous revision) |
| 155 | $ids = array_filter( $ids ); |
| 156 | /** @var AbstractRevision[] $found */ |
| 157 | $found = $this->storage->getMulti( $revisionType, $ids ); |
| 158 | foreach ( $found as $rev ) { |
| 159 | $revisions[$rev->getRevisionId()->getAlphadecimal()] = $rev; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // preload all current versions |
| 164 | foreach ( $collectionIds as $revisionType => $ids ) { |
| 165 | $queries = []; |
| 166 | foreach ( $ids as $uuid ) { |
| 167 | $queries[] = [ 'rev_type_id' => $uuid ]; |
| 168 | } |
| 169 | |
| 170 | $found = $this->storage->findMulti( $revisionType, |
| 171 | $queries, |
| 172 | [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ] |
| 173 | ); |
| 174 | |
| 175 | /** @var AbstractRevision[] $result */ |
| 176 | foreach ( $found as $result ) { |
| 177 | $rev = reset( $result ); |
| 178 | $cacheKey = $this->getCurrentRevisionCacheKey( $rev ); |
| 179 | $this->currentRevisionsCache[$cacheKey] = $rev->getRevisionId(); |
| 180 | $revisions[$rev->getRevisionId()->getAlphadecimal()] = $rev; |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | $this->revisionCache = array_merge( $this->revisionCache, $revisions ); |
| 185 | $this->postCache = array_merge( $this->postCache, $rootPosts ); |
| 186 | $this->rootPostIdCache = array_merge( $this->rootPostIdCache, $rootPostIds ); |
| 187 | $this->workflowCache = array_merge( $this->workflowCache, $workflows ); |
| 188 | $this->identityMap = array_merge( $this->identityMap, $identityMap ); |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Build a stdClass object that contains all related data models necessary |
| 193 | * for rendering a revision. |
| 194 | * |
| 195 | * @param AbstractRevision $revision |
| 196 | * @param string|null $indexField The field used for pagination |
| 197 | * @param FormatterRow|null $row Row to populate |
| 198 | * @return FormatterRow |
| 199 | * @throws FlowException |
| 200 | */ |
| 201 | protected function buildResult( AbstractRevision $revision, $indexField, ?FormatterRow $row = null ) { |
| 202 | $uuid = $revision->getRevisionId(); |
| 203 | $timestamp = $uuid->getTimestamp(); |
| 204 | |
| 205 | $workflow = $this->getWorkflow( $revision ); |
| 206 | if ( !$workflow ) { |
| 207 | throw new FlowException( |
| 208 | "could not locate workflow for revision {revisionId}", |
| 209 | 'default', |
| 210 | [ 'revisionId' => $revision->getRevisionId()->getAlphadecimal() ] |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | $row = $row ?: new FormatterRow; |
| 215 | $row->revision = $revision; |
| 216 | if ( $this->needsPreviousRevision( $revision ) ) { |
| 217 | $row->previousRevision = $this->getPreviousRevision( $revision ); |
| 218 | } |
| 219 | $row->currentRevision = $this->getCurrentRevision( $revision ); |
| 220 | $row->workflow = $workflow; |
| 221 | |
| 222 | // some core classes that process this row before our formatter |
| 223 | // require a specific field to handle pagination |
| 224 | if ( $indexField && property_exists( $row, $indexField ) ) { |
| 225 | $row->$indexField = $timestamp; |
| 226 | } |
| 227 | |
| 228 | if ( $revision instanceof PostRevision ) { |
| 229 | $row->rootPost = $this->getRootPost( $revision ); |
| 230 | $revision->setRootPost( $row->rootPost ); |
| 231 | $row->isFirstReply = $this->isFirstReply( $revision, $row->rootPost ); |
| 232 | $row->isLastReply = $this->isLastReply( $revision ); |
| 233 | } |
| 234 | |
| 235 | return $row; |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * @param PostRevision $revision |
| 240 | * @param PostRevision $root |
| 241 | * @return bool |
| 242 | */ |
| 243 | protected function isFirstReply( PostRevision $revision, PostRevision $root ) { |
| 244 | // check if it's a first-level reply (not topic title, but the level just below that) |
| 245 | if ( !$root->getPostId()->equals( $revision->getReplyToId() ) ) { |
| 246 | return false; |
| 247 | } |
| 248 | |
| 249 | // we can use the timestamps to check if the reply was created at roughly the same time the |
| 250 | // topic was created if they're 0 or 1 seconds apart, they must have been created in the |
| 251 | // same request unless our servers are extremely slow and can't create topic + first reply |
| 252 | // in < 1 seconds, this should be a pretty safe method to detect first reply |
| 253 | if ( (int)$revision->getPostId()->getTimestamp( TS_UNIX ) - |
| 254 | (int)$root->getPostId()->getTimestamp( TS_UNIX ) >= 2 |
| 255 | ) { |
| 256 | return false; |
| 257 | } |
| 258 | |
| 259 | return true; |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * @param PostRevision $revision |
| 264 | * @return bool |
| 265 | */ |
| 266 | protected function isLastReply( PostRevision $revision ) { |
| 267 | if ( $revision->isTopicTitle() ) { |
| 268 | return false; |
| 269 | } |
| 270 | $reply = $revision->getReplyToId()->getAlphadecimal(); |
| 271 | if ( !isset( $this->identityMap[$reply] ) ) { |
| 272 | wfDebugLog( 'Flow', __METHOD__ . ": Missing $reply in identity map" ); |
| 273 | return false; |
| 274 | } |
| 275 | $parent = $this->identityMap[$revision->getReplyToId()->getAlphadecimal()]; |
| 276 | $keys = array_keys( $parent['children'] ); |
| 277 | return end( $keys ) === $revision->getPostId()->getAlphadecimal(); |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * @param AbstractRevision $revision |
| 282 | * @return Workflow |
| 283 | */ |
| 284 | protected function getWorkflow( AbstractRevision $revision ) { |
| 285 | if ( $revision instanceof PostRevision ) { |
| 286 | $rootPostId = $this->getRootPostId( $revision ); |
| 287 | return $this->getWorkflowById( $rootPostId ); |
| 288 | } elseif ( $revision instanceof Header ) { |
| 289 | return $this->getWorkflowById( $revision->getWorkflowId() ); |
| 290 | } elseif ( $revision instanceof PostSummary ) { |
| 291 | return $this->getWorkflowById( $revision->getCollection()->getWorkflowId() ); |
| 292 | } else { |
| 293 | throw new InvalidArgumentException( 'Unsupported revision type ' . get_class( $revision ) ); |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Decides if the given abstract revision needs its prior revision for formatting |
| 299 | * @param AbstractRevision $revision |
| 300 | * @return bool true when the previous revision to this should be loaded |
| 301 | */ |
| 302 | protected function needsPreviousRevision( AbstractRevision $revision ) { |
| 303 | // crappy special case needs the previous object so it can show the title |
| 304 | // but only when outputting a full history api result(we don't know that here) |
| 305 | return $revision instanceof PostRevision |
| 306 | && $revision->getChangeType() === 'edit-title'; |
| 307 | } |
| 308 | |
| 309 | /** |
| 310 | * Retrieves the previous revision for a given AbstractRevision |
| 311 | * @param AbstractRevision $revision The revision to retrieve the previous revision for. |
| 312 | * @return AbstractRevision|null AbstractRevision of the previous revision or null if no |
| 313 | * previous revision. |
| 314 | */ |
| 315 | protected function getPreviousRevision( AbstractRevision $revision ) { |
| 316 | $previousRevisionId = $revision->getPrevRevisionId(); |
| 317 | |
| 318 | // original post; no previous revision |
| 319 | if ( $previousRevisionId === null ) { |
| 320 | return null; |
| 321 | } |
| 322 | |
| 323 | if ( !isset( $this->revisionCache[$previousRevisionId->getAlphadecimal()] ) ) { |
| 324 | $this->revisionCache[$previousRevisionId->getAlphadecimal()] = |
| 325 | $this->storage->get( 'PostRevision', $previousRevisionId ); |
| 326 | } |
| 327 | |
| 328 | return $this->revisionCache[$previousRevisionId->getAlphadecimal()]; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Retrieves the current revision for a given AbstractRevision |
| 333 | * @param AbstractRevision $revision The revision to retrieve the current revision for. |
| 334 | * @return AbstractRevision|null AbstractRevision of the current revision. |
| 335 | */ |
| 336 | protected function getCurrentRevision( AbstractRevision $revision ) { |
| 337 | $cacheKey = $this->getCurrentRevisionCacheKey( $revision ); |
| 338 | if ( !isset( $this->currentRevisionsCache[$cacheKey] ) ) { |
| 339 | $currentRevision = $revision->getCollection()->getLastRevision(); |
| 340 | |
| 341 | $this->currentRevisionsCache[$cacheKey] = $currentRevision->getRevisionId(); |
| 342 | $this->revisionCache[$currentRevision->getRevisionId()->getAlphadecimal()] = $currentRevision; |
| 343 | } |
| 344 | |
| 345 | $currentRevisionId = $this->currentRevisionsCache[$cacheKey]; |
| 346 | return $this->revisionCache[$currentRevisionId->getAlphadecimal()]; |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * Retrieves the root post for a given PostRevision |
| 351 | * @param PostRevision $revision The revision to retrieve the root post for. |
| 352 | * @return PostRevision PostRevision of the root post. |
| 353 | */ |
| 354 | protected function getRootPost( PostRevision $revision ) { |
| 355 | if ( $revision->isTopicTitle() ) { |
| 356 | return $revision; |
| 357 | } |
| 358 | $rootPostId = $this->getRootPostId( $revision ); |
| 359 | |
| 360 | if ( !isset( $this->postCache[$rootPostId->getAlphadecimal()] ) ) { |
| 361 | throw new RuntimeException( 'Did not load root post ' . $rootPostId->getAlphadecimal() ); |
| 362 | } |
| 363 | |
| 364 | $rootPost = $this->postCache[$rootPostId->getAlphadecimal()]; |
| 365 | if ( !$rootPost ) { |
| 366 | throw new RuntimeException( 'Did not locate root post ' . $rootPostId->getAlphadecimal() ); |
| 367 | } |
| 368 | if ( !$rootPost->isTopicTitle() ) { |
| 369 | throw new RuntimeException( "Not a topic title: " . $rootPost->getRevisionId()->getAlphadecimal() ); |
| 370 | } |
| 371 | |
| 372 | return $rootPost; |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * Gets the root post ID for a given PostRevision |
| 377 | * @param PostRevision $revision The revision to get the root post ID for. |
| 378 | * @return UUID The UUID for the root post. |
| 379 | */ |
| 380 | protected function getRootPostId( PostRevision $revision ) { |
| 381 | $postId = $revision->getPostId(); |
| 382 | if ( $revision->isTopicTitle() ) { |
| 383 | return $postId; |
| 384 | } elseif ( isset( $this->rootPostIdCache[$postId->getAlphadecimal()] ) ) { |
| 385 | return $this->rootPostIdCache[$postId->getAlphadecimal()]; |
| 386 | } else { |
| 387 | throw new RuntimeException( "Unable to find root post ID for post " . $postId->getAlphadecimal() ); |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | /** |
| 392 | * Gets a Workflow object given its ID |
| 393 | * @param UUID $workflowId |
| 394 | * @return Workflow |
| 395 | */ |
| 396 | protected function getWorkflowById( UUID $workflowId ) { |
| 397 | $alpha = $workflowId->getAlphadecimal(); |
| 398 | if ( !isset( $this->workflowCache[$alpha] ) ) { |
| 399 | $this->workflowCache[$alpha] = $this->storage->get( 'Workflow', $workflowId ); |
| 400 | } |
| 401 | return $this->workflowCache[$alpha]; |
| 402 | } |
| 403 | |
| 404 | /** |
| 405 | * @param AbstractRevision $revision |
| 406 | * @return string |
| 407 | */ |
| 408 | protected function getCurrentRevisionCacheKey( AbstractRevision $revision ) { |
| 409 | return $revision->getRevisionType() . '-' . $revision->getCollectionId()->getAlphadecimal(); |
| 410 | } |
| 411 | } |