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