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