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    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}