Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractQuery
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 13
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadMetadataBatch
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
272
 buildResult
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 isFirstReply
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isLastReply
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getWorkflow
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 needsPreviousRevision
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getPreviousRevision
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCurrentRevision
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getRootPost
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getRootPostId
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getWorkflowById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCurrentRevisionCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Flow\Formatter;
4
5use Flow\Data\ManagerGroup;
6use Flow\Exception\FlowException;
7use Flow\Model\AbstractRevision;
8use Flow\Model\Header;
9use Flow\Model\PostRevision;
10use Flow\Model\PostSummary;
11use Flow\Model\UUID;
12use Flow\Model\Workflow;
13use Flow\Repository\TreeRepository;
14use InvalidArgumentException;
15use RuntimeException;
16use 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 */
24abstract 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}