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 | /** |
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( |
212 | "could not locate workflow for revision {revisionId}", |
213 | 'default', |
214 | [ 'revisionId' => $revision->getRevisionId()->getAlphadecimal() ] |
215 | ); |
216 | } |
217 | |
218 | $row = $row ?: new FormatterRow; |
219 | $row->revision = $revision; |
220 | if ( $this->needsPreviousRevision( $revision ) ) { |
221 | $row->previousRevision = $this->getPreviousRevision( $revision ); |
222 | } |
223 | $row->currentRevision = $this->getCurrentRevision( $revision ); |
224 | $row->workflow = $workflow; |
225 | |
226 | // some core classes that process this row before our formatter |
227 | // require a specific field to handle pagination |
228 | if ( $indexField && property_exists( $row, $indexField ) ) { |
229 | $row->$indexField = $timestamp; |
230 | } |
231 | |
232 | if ( $revision instanceof PostRevision ) { |
233 | $row->rootPost = $this->getRootPost( $revision ); |
234 | $revision->setRootPost( $row->rootPost ); |
235 | $row->isFirstReply = $this->isFirstReply( $revision, $row->rootPost ); |
236 | $row->isLastReply = $this->isLastReply( $revision ); |
237 | } |
238 | |
239 | return $row; |
240 | } |
241 | |
242 | /** |
243 | * @param PostRevision $revision |
244 | * @param PostRevision $root |
245 | * @return bool |
246 | */ |
247 | protected function isFirstReply( PostRevision $revision, PostRevision $root ) { |
248 | // check if it's a first-level reply (not topic title, but the level just below that) |
249 | if ( !$root->getPostId()->equals( $revision->getReplyToId() ) ) { |
250 | return false; |
251 | } |
252 | |
253 | // we can use the timestamps to check if the reply was created at roughly the same time the |
254 | // topic was created if they're 0 or 1 seconds apart, they must have been created in the |
255 | // same request unless our servers are extremely slow and can't create topic + first reply |
256 | // in < 1 seconds, this should be a pretty safe method to detect first reply |
257 | if ( (int)$revision->getPostId()->getTimestamp( TS_UNIX ) - |
258 | (int)$root->getPostId()->getTimestamp( TS_UNIX ) >= 2 |
259 | ) { |
260 | return false; |
261 | } |
262 | |
263 | return true; |
264 | } |
265 | |
266 | /** |
267 | * @param PostRevision $revision |
268 | * @return bool |
269 | */ |
270 | protected function isLastReply( PostRevision $revision ) { |
271 | if ( $revision->isTopicTitle() ) { |
272 | return false; |
273 | } |
274 | $reply = $revision->getReplyToId()->getAlphadecimal(); |
275 | if ( !isset( $this->identityMap[$reply] ) ) { |
276 | wfDebugLog( 'Flow', __METHOD__ . ": Missing $reply in identity map" ); |
277 | return false; |
278 | } |
279 | $parent = $this->identityMap[$revision->getReplyToId()->getAlphadecimal()]; |
280 | $keys = array_keys( $parent['children'] ); |
281 | return end( $keys ) === $revision->getPostId()->getAlphadecimal(); |
282 | } |
283 | |
284 | /** |
285 | * @param AbstractRevision $revision |
286 | * @return Workflow |
287 | */ |
288 | protected function getWorkflow( AbstractRevision $revision ) { |
289 | if ( $revision instanceof PostRevision ) { |
290 | $rootPostId = $this->getRootPostId( $revision ); |
291 | return $this->getWorkflowById( $rootPostId ); |
292 | } elseif ( $revision instanceof Header ) { |
293 | return $this->getWorkflowById( $revision->getWorkflowId() ); |
294 | } elseif ( $revision instanceof PostSummary ) { |
295 | return $this->getWorkflowById( $revision->getCollection()->getWorkflowId() ); |
296 | } else { |
297 | throw new InvalidArgumentException( 'Unsupported revision type ' . get_class( $revision ) ); |
298 | } |
299 | } |
300 | |
301 | /** |
302 | * Decides if the given abstract revision needs its prior revision for formatting |
303 | * @param AbstractRevision $revision |
304 | * @return bool true when the previous revision to this should be loaded |
305 | */ |
306 | protected function needsPreviousRevision( AbstractRevision $revision ) { |
307 | // crappy special case needs the previous object so it can show the title |
308 | // but only when outputting a full history api result(we don't know that here) |
309 | return $revision instanceof PostRevision |
310 | && $revision->getChangeType() === 'edit-title'; |
311 | } |
312 | |
313 | /** |
314 | * Retrieves the previous revision for a given AbstractRevision |
315 | * @param AbstractRevision $revision The revision to retrieve the previous revision for. |
316 | * @return AbstractRevision|null AbstractRevision of the previous revision or null if no |
317 | * previous revision. |
318 | */ |
319 | protected function getPreviousRevision( AbstractRevision $revision ) { |
320 | $previousRevisionId = $revision->getPrevRevisionId(); |
321 | |
322 | // original post; no previous revision |
323 | if ( $previousRevisionId === null ) { |
324 | return null; |
325 | } |
326 | |
327 | if ( !isset( $this->revisionCache[$previousRevisionId->getAlphadecimal()] ) ) { |
328 | $this->revisionCache[$previousRevisionId->getAlphadecimal()] = |
329 | $this->storage->get( 'PostRevision', $previousRevisionId ); |
330 | } |
331 | |
332 | return $this->revisionCache[$previousRevisionId->getAlphadecimal()]; |
333 | } |
334 | |
335 | /** |
336 | * Retrieves the current revision for a given AbstractRevision |
337 | * @param AbstractRevision $revision The revision to retrieve the current revision for. |
338 | * @return AbstractRevision|null AbstractRevision of the current revision. |
339 | */ |
340 | protected function getCurrentRevision( AbstractRevision $revision ) { |
341 | $cacheKey = $this->getCurrentRevisionCacheKey( $revision ); |
342 | if ( !isset( $this->currentRevisionsCache[$cacheKey] ) ) { |
343 | $currentRevision = $revision->getCollection()->getLastRevision(); |
344 | |
345 | $this->currentRevisionsCache[$cacheKey] = $currentRevision->getRevisionId(); |
346 | $this->revisionCache[$currentRevision->getRevisionId()->getAlphadecimal()] = $currentRevision; |
347 | } |
348 | |
349 | $currentRevisionId = $this->currentRevisionsCache[$cacheKey]; |
350 | return $this->revisionCache[$currentRevisionId->getAlphadecimal()]; |
351 | } |
352 | |
353 | /** |
354 | * Retrieves the root post for a given PostRevision |
355 | * @param PostRevision $revision The revision to retrieve the root post for. |
356 | * @return PostRevision PostRevision of the root post. |
357 | */ |
358 | protected function getRootPost( PostRevision $revision ) { |
359 | if ( $revision->isTopicTitle() ) { |
360 | return $revision; |
361 | } |
362 | $rootPostId = $this->getRootPostId( $revision ); |
363 | |
364 | if ( !isset( $this->postCache[$rootPostId->getAlphadecimal()] ) ) { |
365 | throw new RuntimeException( 'Did not load root post ' . $rootPostId->getAlphadecimal() ); |
366 | } |
367 | |
368 | $rootPost = $this->postCache[$rootPostId->getAlphadecimal()]; |
369 | if ( !$rootPost ) { |
370 | throw new RuntimeException( 'Did not locate root post ' . $rootPostId->getAlphadecimal() ); |
371 | } |
372 | if ( !$rootPost->isTopicTitle() ) { |
373 | throw new RuntimeException( "Not a topic title: " . $rootPost->getRevisionId()->getAlphadecimal() ); |
374 | } |
375 | |
376 | return $rootPost; |
377 | } |
378 | |
379 | /** |
380 | * Gets the root post ID for a given PostRevision |
381 | * @param PostRevision $revision The revision to get the root post ID for. |
382 | * @return UUID The UUID for the root post. |
383 | */ |
384 | protected function getRootPostId( PostRevision $revision ) { |
385 | $postId = $revision->getPostId(); |
386 | if ( $revision->isTopicTitle() ) { |
387 | return $postId; |
388 | } elseif ( isset( $this->rootPostIdCache[$postId->getAlphadecimal()] ) ) { |
389 | return $this->rootPostIdCache[$postId->getAlphadecimal()]; |
390 | } else { |
391 | throw new RuntimeException( "Unable to find root post ID for post " . $postId->getAlphadecimal() ); |
392 | } |
393 | } |
394 | |
395 | /** |
396 | * Gets a Workflow object given its ID |
397 | * @param UUID $workflowId |
398 | * @return Workflow |
399 | */ |
400 | protected function getWorkflowById( UUID $workflowId ) { |
401 | $alpha = $workflowId->getAlphadecimal(); |
402 | if ( !isset( $this->workflowCache[$alpha] ) ) { |
403 | $this->workflowCache[$alpha] = $this->storage->get( 'Workflow', $workflowId ); |
404 | } |
405 | return $this->workflowCache[$alpha]; |
406 | } |
407 | |
408 | /** |
409 | * @param AbstractRevision $revision |
410 | * @return string |
411 | */ |
412 | protected function getCurrentRevisionCacheKey( AbstractRevision $revision ) { |
413 | return $revision->getRevisionType() . '-' . $revision->getCollectionId()->getAlphadecimal(); |
414 | } |
415 | } |