Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 154
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 / 154
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 / 20
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 / 5
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( "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}