Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContributionsQuery
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 7
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getResults
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
182
 excludeFromContributions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildConditions
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
132
 queryRevisions
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
30
 loadRevisions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 validate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow\Formatter;
4
5use Flow\Data\ManagerGroup;
6use Flow\Data\Storage\RevisionStorage;
7use Flow\DbFactory;
8use Flow\Exception\FlowException;
9use Flow\FlowActions;
10use Flow\Model\AbstractRevision;
11use Flow\Model\UUID;
12use Flow\Repository\TreeRepository;
13use InvalidArgumentException;
14use MediaWiki\Pager\ContribsPager;
15use MediaWiki\Pager\DeletedContribsPager;
16use MediaWiki\User\UserIdentityLookup;
17use MediaWiki\WikiMap\WikiMap;
18use Wikimedia\Rdbms\IResultWrapper;
19
20class ContributionsQuery extends AbstractQuery {
21
22    /**
23     * @var DbFactory
24     */
25    protected $dbFactory;
26
27    /**
28     * @var FlowActions
29     */
30    protected $actions;
31
32    /** @var UserIdentityLookup */
33    private $userIdentityLookup;
34
35    /**
36     * @param ManagerGroup $storage
37     * @param TreeRepository $treeRepo
38     * @param DbFactory $dbFactory
39     * @param FlowActions $actions
40     * @param UserIdentityLookup $userIdentityLookup
41     */
42    public function __construct(
43        ManagerGroup $storage,
44        TreeRepository $treeRepo,
45        DbFactory $dbFactory,
46        FlowActions $actions,
47        UserIdentityLookup $userIdentityLookup
48    ) {
49        parent::__construct( $storage, $treeRepo );
50        $this->dbFactory = $dbFactory;
51        $this->actions = $actions;
52        $this->userIdentityLookup = $userIdentityLookup;
53    }
54
55    /**
56     * @param ContribsPager|DeletedContribsPager $pager Object hooked into
57     * @param string $offset Index offset, inclusive
58     * @param int $limit Exact query limit
59     * @param bool $descending Query direction, false for ascending, true for descending
60     * @param array $rangeOffsets Query range, in the format of [ endOffset, startOffset ]
61     * @return FormatterRow[]
62     */
63    public function getResults( $pager, $offset, $limit, $descending, $rangeOffsets = [] ) {
64        // When ORES hidenondamaging filter is used, Flow entries should be skipped
65        // because they are not scored.
66        if ( $pager->getRequest()->getBool( 'hidenondamaging' ) ) {
67            return [];
68        }
69
70        // build DB query conditions
71        $conditions = $this->buildConditions( $pager, $offset, $descending, $rangeOffsets );
72
73        $types = [
74            // revision class => block type
75            'PostRevision' => 'topic',
76            'Header' => 'header',
77            'PostSummary' => 'topicsummary'
78        ];
79
80        $results = [];
81        foreach ( $types as $revisionClass => $blockType ) {
82            // query DB for requested revisions
83            $rows = $this->queryRevisions( $conditions, $limit, $revisionClass );
84
85            // turn DB data into revision objects
86            $revisions = $this->loadRevisions( $rows, $revisionClass );
87
88            $this->loadMetadataBatch( $revisions );
89            foreach ( $revisions as $revision ) {
90                try {
91                    if ( $this->excludeFromContributions( $revision ) ) {
92                        continue;
93                    }
94
95                    $result = $pager instanceof ContribsPager ? new ContributionsRow : new DeletedContributionsRow;
96                    $result = $this->buildResult( $revision, $pager->getIndexField(), $result );
97                    $deleted = $result->currentRevision->isDeleted() || $result->workflow->isDeleted();
98
99                    if (
100                        $result instanceof ContributionsRow &&
101                        ( $deleted || $result->currentRevision->isSuppressed() )
102                    ) {
103                        // don't show deleted or suppressed entries in Special:Contributions
104                        continue;
105                    }
106                    if ( $result instanceof DeletedContributionsRow && !$deleted ) {
107                        // only show deleted entries in Special:DeletedContributions
108                        continue;
109                    }
110
111                    $results[] = $result;
112                } catch ( FlowException $e ) {
113                    \MWExceptionHandler::logException( $e );
114                }
115            }
116        }
117
118        return $results;
119    }
120
121    /**
122     * @param AbstractRevision $revision
123     * @return bool
124     */
125    private function excludeFromContributions( AbstractRevision $revision ) {
126        return (bool)$this->actions->getValue( $revision->getChangeType(), 'exclude_from_contributions' );
127    }
128
129    /**
130     * @param ContribsPager|DeletedContribsPager $pager Object hooked into
131     * @param string $offset Index offset, inclusive
132     * @param bool $descending Query direction, false for ascending, true for descending
133     * @param array $rangeOffsets Query range, in the format of [ endOffset, startOffset ]
134     * @return array Query conditions
135     */
136    protected function buildConditions( $pager, $offset, $descending, $rangeOffsets = [] ) {
137        $conditions = [];
138
139        $isContribsPager = $pager instanceof ContribsPager;
140        $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $pager->getTarget() );
141        if ( $userIdentity && $userIdentity->isRegistered() ) {
142            $conditions['rev_user_id'] = $userIdentity->getId();
143            $conditions['rev_user_ip'] = null;
144            $conditions['rev_user_wiki'] = WikiMap::getCurrentWikiId();
145        } else {
146            $conditions['rev_user_id'] = 0;
147            $conditions['rev_user_ip'] = $pager->getTarget();
148            $conditions['rev_user_wiki'] = WikiMap::getCurrentWikiId();
149        }
150
151        if ( $isContribsPager && $pager->isNewOnly() ) {
152            $conditions['rev_parent_id'] = null;
153            $conditions['rev_type'] = 'post';
154        }
155
156        $dbr = $this->dbFactory->getDB( DB_REPLICA );
157        // Make offset parameter.
158        if ( $offset ) {
159            $offsetUUID = UUID::getComparisonUUID( $offset );
160            $direction = $descending ? '>' : '<';
161            $conditions[] = $dbr->buildComparison( $direction, [ 'rev_id' => $offsetUUID->getBinary() ] );
162        }
163        if ( $rangeOffsets ) {
164            $endUUID = UUID::getComparisonUUID( $rangeOffsets[0] );
165            $conditions[] = $dbr->buildComparison( '<', [ 'rev_id' => $endUUID->getBinary() ] );
166            // The DeletedContribsPager is only a ReverseChronologicalPager for now.
167            if ( count( $rangeOffsets ) > 1 && $rangeOffsets[1] ) {
168                $startUUID = UUID::getComparisonUUID( $rangeOffsets[1] );
169                $conditions[] = $dbr->buildComparison( '>=', [ 'rev_id' => $startUUID->getBinary() ] );
170            }
171        }
172
173        // Find only within requested wiki/namespace
174        $conditions['workflow_wiki'] = WikiMap::getCurrentWikiId();
175        if ( $pager->getNamespace() !== '' ) {
176            $conditions['workflow_namespace'] = $pager->getNamespace();
177        }
178
179        return $conditions;
180    }
181
182    /**
183     * @param array $conditions
184     * @param int $limit
185     * @param string $revisionClass Storage type (e.g. "PostRevision", "Header")
186     * @return IResultWrapper
187     */
188    protected function queryRevisions( $conditions, $limit, $revisionClass ) {
189        $dbr = $this->dbFactory->getDB( DB_REPLICA );
190
191        switch ( $revisionClass ) {
192            case 'PostRevision':
193                return $dbr->select(
194                    [
195                        'flow_revision', // revisions to find
196                        'flow_tree_revision', // resolve to post id
197                        'flow_tree_node', // resolve to root post (topic title)
198                        'flow_workflow', // resolve to workflow, to test if in correct wiki/namespace
199                    ],
200                    [ '*' ],
201                    $conditions,
202                    __METHOD__,
203                    [
204                        'LIMIT' => $limit,
205                        'ORDER BY' => 'rev_id DESC',
206                    ],
207                    [
208                        'flow_tree_revision' => [
209                            'INNER JOIN',
210                            [ 'tree_rev_id = rev_id' ]
211                        ],
212                        'flow_tree_node' => [
213                            'INNER JOIN',
214                            [
215                                'tree_descendant_id = tree_rev_descendant_id',
216                                // the one with max tree_depth will be root,
217                                // which will have the matching workflow id
218                            ]
219                        ],
220                        'flow_workflow' => [
221                            'INNER JOIN',
222                            [ 'workflow_id = tree_ancestor_id' ]
223                        ],
224                    ]
225                );
226
227            case 'Header':
228                return $dbr->select(
229                    [ 'flow_revision', 'flow_workflow' ],
230                    [ '*' ],
231                    $conditions,
232                    __METHOD__,
233                    [
234                        'LIMIT' => $limit,
235                        'ORDER BY' => 'rev_id DESC',
236                    ],
237                    [
238                        'flow_workflow' => [
239                            'INNER JOIN',
240                            [ 'workflow_id = rev_type_id', 'rev_type' => 'header' ]
241                        ],
242                    ]
243                );
244
245            case 'PostSummary':
246                return $dbr->select(
247                    [ 'flow_revision', 'flow_tree_node', 'flow_workflow' ],
248                    [ '*' ],
249                    $conditions,
250                    __METHOD__,
251                    [
252                        'LIMIT' => $limit,
253                        'ORDER BY' => 'rev_id DESC',
254                    ],
255                    [
256                        'flow_tree_node' => [
257                            'INNER JOIN',
258                            [ 'tree_descendant_id = rev_type_id', 'rev_type' => 'post-summary' ]
259                        ],
260                        'flow_workflow' => [
261                            'INNER JOIN',
262                            [ 'workflow_id = tree_ancestor_id' ]
263                        ]
264                    ]
265                );
266
267            default:
268                throw new InvalidArgumentException( 'Unsupported revision type ' . $revisionClass );
269        }
270    }
271
272    /**
273     * Turns DB data into revision objects.
274     *
275     * @param IResultWrapper $rows
276     * @param string $revisionClass Class of revision object to build: PostRevision|Header
277     * @return array
278     */
279    protected function loadRevisions( IResultWrapper $rows, $revisionClass ) {
280        $revisions = [];
281        foreach ( $rows as $row ) {
282            $revisions[UUID::create( $row->rev_id )->getAlphadecimal()] = (array)$row;
283        }
284
285        // get content in external storage
286        $res = [ $revisions ];
287        $res = RevisionStorage::mergeExternalContent( $res );
288        $revisions = reset( $res );
289
290        // we have all required data to build revision
291        $mapper = $this->storage->getStorage( $revisionClass )->getMapper();
292        $revisions = array_map( [ $mapper, 'fromStorageRow' ], $revisions );
293
294        // @todo: we may already be able to build workflowCache (and rootPostIdCache) from this DB data
295
296        return $revisions;
297    }
298
299    /**
300     * When retrieving revisions from DB, self::mergeExternalContent will be
301     * called to fetch the content. This could fail, resulting in the content
302     * being a 'false' value.
303     *
304     * @inheritDoc
305     */
306    public function validate( array $row ) {
307        return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
308    }
309}