Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 333
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageHistoryCountHandler
0.00% covered (danger)
0.00%
0 / 333
0.00% covered (danger)
0.00%
0 / 23
5700
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectHelper
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateParameterCombination
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 run
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
42
 getCount
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
306
 getCurrentRevision
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getPage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getLastModified
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLastModifiedTimes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 loggingTableTime
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getEtag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedCount
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 getAnonCount
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getBotCount
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 getEditorsCount
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRevertedCount
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 getMinorCount
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getEditsCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionOrThrow
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 orderRevisions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
42
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use ChangeTags;
6use MediaWiki\Page\ExistingPageRecord;
7use MediaWiki\Page\PageLookup;
8use MediaWiki\Permissions\GroupPermissionsLookup;
9use MediaWiki\Rest\Handler\Helper\PageRedirectHelper;
10use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory;
11use MediaWiki\Rest\LocalizedHttpException;
12use MediaWiki\Rest\Response;
13use MediaWiki\Rest\SimpleHandler;
14use MediaWiki\Revision\RevisionRecord;
15use MediaWiki\Revision\RevisionStore;
16use MediaWiki\Storage\NameTableAccessException;
17use MediaWiki\Storage\NameTableStore;
18use MediaWiki\Storage\NameTableStoreFactory;
19use WANObjectCache;
20use Wikimedia\Message\MessageValue;
21use Wikimedia\Message\ParamType;
22use Wikimedia\Message\ScalarParam;
23use Wikimedia\ParamValidator\ParamValidator;
24use Wikimedia\Rdbms\IConnectionProvider;
25
26/**
27 * Handler class for Core REST API endpoints that perform operations on revisions
28 */
29class PageHistoryCountHandler extends SimpleHandler {
30
31    /** The maximum number of counts to return per type of revision */
32    private const COUNT_LIMITS = [
33        'anonymous' => 10000,
34        'bot' => 10000,
35        'editors' => 25000,
36        'edits' => 30000,
37        'minor' => 1000,
38        'reverted' => 30000
39    ];
40
41    private const DEPRECATED_COUNT_TYPES = [
42        'anonedits' => 'anonymous',
43        'botedits' => 'bot',
44        'revertededits' => 'reverted'
45    ];
46
47    private const MAX_AGE_200 = 60;
48
49    private RevisionStore $revisionStore;
50    private NameTableStore $changeTagDefStore;
51    private GroupPermissionsLookup $groupPermissionsLookup;
52    private IConnectionProvider $dbProvider;
53    private PageLookup $pageLookup;
54    private WANObjectCache $cache;
55    private PageRestHelperFactory $helperFactory;
56
57    /** @var RevisionRecord|false|null */
58    private $revision = false;
59
60    /** @var array|null */
61    private $lastModifiedTimes;
62
63    /** @var ExistingPageRecord|false|null */
64    private $page = false;
65
66    /**
67     * @param RevisionStore $revisionStore
68     * @param NameTableStoreFactory $nameTableStoreFactory
69     * @param GroupPermissionsLookup $groupPermissionsLookup
70     * @param IConnectionProvider $dbProvider
71     * @param WANObjectCache $cache
72     * @param PageLookup $pageLookup
73     * @param PageRestHelperFactory $helperFactory
74     */
75    public function __construct(
76        RevisionStore $revisionStore,
77        NameTableStoreFactory $nameTableStoreFactory,
78        GroupPermissionsLookup $groupPermissionsLookup,
79        IConnectionProvider $dbProvider,
80        WANObjectCache $cache,
81        PageLookup $pageLookup,
82        PageRestHelperFactory $helperFactory
83    ) {
84        $this->revisionStore = $revisionStore;
85        $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
86        $this->groupPermissionsLookup = $groupPermissionsLookup;
87        $this->dbProvider = $dbProvider;
88        $this->cache = $cache;
89        $this->pageLookup = $pageLookup;
90        $this->helperFactory = $helperFactory;
91    }
92
93    private function getRedirectHelper(): PageRedirectHelper {
94        return $this->helperFactory->newPageRedirectHelper(
95            $this->getResponseFactory(),
96            $this->getRouter(),
97            $this->getPath(),
98            $this->getRequest()
99        );
100    }
101
102    private function normalizeType( $type ) {
103        return self::DEPRECATED_COUNT_TYPES[$type] ?? $type;
104    }
105
106    /**
107     * Validates that the provided parameter combination is supported.
108     *
109     * @param string $type
110     * @throws LocalizedHttpException
111     */
112    private function validateParameterCombination( $type ) {
113        $params = $this->getValidatedParams();
114        if ( !$params ) {
115            return;
116        }
117
118        if ( $params['from'] || $params['to'] ) {
119            if ( $type === 'edits' || $type === 'editors' ) {
120                if ( !$params['from'] || !$params['to'] ) {
121                    throw new LocalizedHttpException(
122                        new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
123                        400
124                    );
125                }
126            } else {
127                throw new LocalizedHttpException(
128                    new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
129                    400
130                );
131            }
132        }
133    }
134
135    /**
136     * @param string $title the title of the page to load history for
137     * @param string $type the validated count type
138     * @return Response
139     * @throws LocalizedHttpException
140     */
141    public function run( $title, $type ) {
142        $normalizedType = $this->normalizeType( $type );
143        $this->validateParameterCombination( $normalizedType );
144        $params = $this->getValidatedParams();
145        $page = $this->getPage();
146
147        if ( !$page ) {
148            throw new LocalizedHttpException(
149                new MessageValue( 'rest-nonexistent-title',
150                    [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
151                ),
152                404
153            );
154        }
155
156        if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) {
157            throw new LocalizedHttpException(
158                new MessageValue( 'rest-permission-denied-title',
159                    [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
160                ),
161                403
162            );
163        }
164
165        '@phan-var \MediaWiki\Page\ExistingPageRecord $page';
166        $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded(
167            $page,
168            $params['title'] ?? null
169        );
170
171        if ( $redirectResponse !== null ) {
172            return $redirectResponse;
173        }
174
175        $count = $this->getCount( $normalizedType );
176        $countLimit = self::COUNT_LIMITS[$normalizedType];
177        $response = $this->getResponseFactory()->createJson( [
178                'count' => $count > $countLimit ? $countLimit : $count,
179                'limit' => $count > $countLimit
180        ] );
181        $response->setHeader( 'Cache-Control', 'max-age=' . self::MAX_AGE_200 );
182
183        // Inform clients who use a deprecated "type" value, so they can adjust
184        if ( isset( self::DEPRECATED_COUNT_TYPES[$type] ) ) {
185            $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' .
186                '#Get_page_history_counts>; rel="deprecation"';
187            $response->setHeader( 'Deprecation', 'version="v1"' );
188            $response->setHeader( 'Link', $docs );
189        }
190
191        return $response;
192    }
193
194    /**
195     * @param string $type the validated count type
196     * @return int the article count
197     * @throws LocalizedHttpException
198     */
199    private function getCount( $type ) {
200        $pageId = $this->getPage()->getId();
201        switch ( $type ) {
202            case 'anonymous':
203                return $this->getCachedCount( $type,
204                    function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
205                        return $this->getAnonCount( $pageId, $fromRev );
206                    }
207                );
208
209            case 'bot':
210                return $this->getCachedCount( $type,
211                    function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
212                        return $this->getBotCount( $pageId, $fromRev );
213                    }
214                );
215
216            case 'editors':
217                $from = $this->getValidatedParams()['from'] ?? null;
218                $to = $this->getValidatedParams()['to'] ?? null;
219                if ( $from || $to ) {
220                    return $this->getEditorsCount(
221                        $pageId,
222                        $from ? $this->getRevisionOrThrow( $from ) : null,
223                        $to ? $this->getRevisionOrThrow( $to ) : null
224                    );
225                } else {
226                    return $this->getCachedCount( $type,
227                        function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
228                            return $this->getEditorsCount( $pageId, $fromRev );
229                        } );
230                }
231
232            case 'edits':
233                $from = $this->getValidatedParams()['from'] ?? null;
234                $to = $this->getValidatedParams()['to'] ?? null;
235                if ( $from || $to ) {
236                    return $this->getEditsCount(
237                        $pageId,
238                        $from ? $this->getRevisionOrThrow( $from ) : null,
239                        $to ? $this->getRevisionOrThrow( $to ) : null
240                    );
241                } else {
242                    return $this->getCachedCount( $type,
243                        function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
244                            return $this->getEditsCount( $pageId, $fromRev );
245                        }
246                    );
247                }
248
249            case 'reverted':
250                return $this->getCachedCount( $type,
251                    function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
252                        return $this->getRevertedCount( $pageId, $fromRev );
253                    }
254                );
255
256            case 'minor':
257                // The query for minor counts is inefficient for the database for pages with many revisions.
258                // If the specified title contains more revisions than allowed, we will return an error.
259                $editsCount = $this->getCachedCount( 'edits',
260                    function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
261                        return $this->getEditsCount( $pageId, $fromRev );
262                    }
263                );
264                if ( $editsCount > self::COUNT_LIMITS[$type] * 2 ) {
265                    throw new LocalizedHttpException(
266                        new MessageValue( 'rest-pagehistorycount-too-many-revisions' ),
267                        500
268                    );
269                }
270                return $this->getCachedCount( $type,
271                    function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
272                        return $this->getMinorCount( $pageId, $fromRev );
273                    }
274                );
275
276            default:
277                throw new LocalizedHttpException(
278                    new MessageValue( 'rest-pagehistorycount-type-unrecognized',
279                        [ new ScalarParam( ParamType::PLAINTEXT, $type ) ]
280                    ),
281                    500
282                );
283        }
284    }
285
286    /**
287     * @return RevisionRecord|null current revision or false if unable to retrieve revision
288     */
289    private function getCurrentRevision(): ?RevisionRecord {
290        if ( $this->revision === false ) {
291            $page = $this->getPage();
292            if ( $page ) {
293                $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?: null;
294            } else {
295                $this->revision = null;
296            }
297        }
298        return $this->revision;
299    }
300
301    /**
302     * @return ExistingPageRecord|null
303     */
304    private function getPage(): ?ExistingPageRecord {
305        if ( $this->page === false ) {
306            $this->page = $this->pageLookup->getExistingPageByText(
307                $this->getValidatedParams()['title']
308            );
309        }
310        return $this->page;
311    }
312
313    /**
314     * Returns latest of 2 timestamps:
315     * 1. Current revision
316     * 2. OR entry from the DB logging table for the given page
317     * @return int|null
318     */
319    protected function getLastModified() {
320        $lastModifiedTimes = $this->getLastModifiedTimes();
321        if ( $lastModifiedTimes ) {
322            return max( array_values( $lastModifiedTimes ) );
323        }
324        return null;
325    }
326
327    /**
328     * Returns array with 2 timestamps:
329     * 1. Current revision
330     * 2. OR entry from the DB logging table for the given page
331     * @return array|null
332     */
333    protected function getLastModifiedTimes() {
334        $currentRev = $this->getCurrentRevision();
335        if ( !$currentRev ) {
336            return null;
337        }
338        if ( $this->lastModifiedTimes === null ) {
339            $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() );
340            $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
341            $this->lastModifiedTimes = [
342                'currentRevTS' => $currentRevTime,
343                'dependencyModTS' => $loggingTableTime
344            ];
345        }
346        return $this->lastModifiedTimes;
347    }
348
349    /**
350     * Return timestamp of latest entry in logging table for given page id
351     * @param int $pageId
352     * @return int|null
353     */
354    private function loggingTableTime( $pageId ) {
355        $res = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
356            ->select( 'MAX(log_timestamp)' )
357            ->from( 'logging' )
358            ->where( [ 'log_page' => $pageId ] )
359            ->caller( __METHOD__ )->fetchField();
360        return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null;
361    }
362
363    /**
364     * Choosing to not implement etags in this handler.
365     * Generating an etag when getting revision counts must account for things like visibility settings
366     * (e.g. rev_deleted bit) which requires hitting the database anyway. The response for these
367     * requests are so small that we wouldn't be gaining much efficiency.
368     * Etags are strong validators and if provided would take precedence over
369     * last modified time, a weak validator. We want to ensure only last modified time is used
370     * since it is more efficient than using etags for this particular case.
371     * @return null
372     */
373    protected function getEtag() {
374        return null;
375    }
376
377    /**
378     * @param string $type
379     * @param callable $fetchCount
380     * @return int
381     */
382    private function getCachedCount( $type,
383        callable $fetchCount
384    ) {
385        $pageId = $this->getPage()->getId();
386        return $this->cache->getWithSetCallback(
387            $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
388            WANObjectCache::TTL_WEEK,
389            function ( $oldValue ) use ( $fetchCount ) {
390                $currentRev = $this->getCurrentRevision();
391                if ( $oldValue ) {
392                    // Last modified timestamp was NOT a dependency change (e.g. revdel)
393                    $doIncrementalUpdate = (
394                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
395                        $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS']
396                    );
397                    if ( $doIncrementalUpdate ) {
398                        $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] );
399                        if ( $rev ) {
400                            $additionalCount = $fetchCount( $rev );
401                            return [
402                                'revision' => $currentRev->getId(),
403                                'count' => $oldValue['count'] + $additionalCount,
404                                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
405                                'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
406                            ];
407                        }
408                    }
409                }
410                // Nothing was previously stored, or incremental update was done for too long,
411                // recalculate from scratch.
412                return [
413                    'revision' => $currentRev->getId(),
414                    'count' => $fetchCount(),
415                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
416                    'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
417                ];
418            },
419            [
420                'touchedCallback' => function (){
421                    return $this->getLastModified();
422                },
423                'version' => 2,
424                'lockTSE' => WANObjectCache::TTL_MINUTE * 5
425            ]
426        )['count'];
427    }
428
429    /**
430     * @param int $pageId the id of the page to load history for
431     * @param RevisionRecord|null $fromRev
432     * @return int the count
433     */
434    protected function getAnonCount( $pageId, RevisionRecord $fromRev = null ) {
435        $dbr = $this->dbProvider->getReplicaDatabase();
436        $queryBuilder = $dbr->newSelectQueryBuilder()
437            ->select( '1' )
438            ->from( 'revision' )
439            ->join( 'actor', null, 'rev_actor = actor_id' )
440            ->where( [
441                'rev_page' => $pageId,
442                'actor_user' => null,
443                $dbr->bitAnd( 'rev_deleted',
444                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0,
445            ] )
446            ->limit( self::COUNT_LIMITS['anonymous'] + 1 ); // extra to detect truncation
447
448        if ( $fromRev ) {
449            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
450                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
451                'rev_id' => $fromRev->getId(),
452            ] ) );
453        }
454
455        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
456    }
457
458    /**
459     * @param int $pageId the id of the page to load history for
460     * @param RevisionRecord|null $fromRev
461     * @return int the count
462     */
463    protected function getBotCount( $pageId, RevisionRecord $fromRev = null ) {
464        $dbr = $this->dbProvider->getReplicaDatabase();
465
466        $queryBuilder = $dbr->newSelectQueryBuilder()
467            ->select( '1' )
468            ->from( 'revision' )
469            ->join( 'actor', 'actor_rev_user', 'actor_rev_user.actor_id = rev_actor' )
470            ->where( [ 'rev_page' => intval( $pageId ) ] )
471            ->andWhere( [
472                $dbr->bitAnd(
473                    'rev_deleted',
474                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER
475                ) => 0
476            ] )
477            ->limit( self::COUNT_LIMITS['bot'] + 1 ); // extra to detect truncation
478        $subquery = $queryBuilder->newSubquery()
479            ->select( '1' )
480            ->from( 'user_groups' )
481            ->where( [
482                'actor_rev_user.actor_user = ug_user',
483                'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
484                $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() )
485            ] );
486
487        $queryBuilder->andWhere( 'EXISTS(' . $subquery->getSQL() . ')' );
488        if ( $fromRev ) {
489            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
490                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
491                'rev_id' => $fromRev->getId(),
492            ] ) );
493        }
494
495        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
496    }
497
498    /**
499     * @param int $pageId the id of the page to load history for
500     * @param RevisionRecord|null $fromRev
501     * @param RevisionRecord|null $toRev
502     * @return int the count
503     */
504    protected function getEditorsCount( $pageId,
505        RevisionRecord $fromRev = null,
506        RevisionRecord $toRev = null
507    ) {
508        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
509        return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
510            $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
511    }
512
513    /**
514     * @param int $pageId the id of the page to load history for
515     * @param RevisionRecord|null $fromRev
516     * @return int the count
517     */
518    protected function getRevertedCount( $pageId, RevisionRecord $fromRev = null ) {
519        $tagIds = [];
520
521        foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
522            try {
523                $tagIds[] = $this->changeTagDefStore->getId( $tagName );
524            } catch ( NameTableAccessException $e ) {
525                // If no revisions are tagged with a name, no tag id will be present
526            }
527        }
528        if ( !$tagIds ) {
529            return 0;
530        }
531
532        $dbr = $this->dbProvider->getReplicaDatabase();
533        $queryBuilder = $dbr->newSelectQueryBuilder()
534            ->select( '1' )
535            ->from( 'revision' )
536            ->join( 'change_tag', null, 'ct_rev_id = rev_id' )
537            ->where( [
538                'rev_page' => $pageId,
539                'ct_tag_id' => $tagIds,
540                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
541            ] )
542            ->groupBy( 'rev_id' )
543            ->limit( self::COUNT_LIMITS['reverted'] + 1 ); // extra to detect truncation
544
545        if ( $fromRev ) {
546            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
547                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
548                'rev_id' => $fromRev->getId(),
549            ] ) );
550        }
551
552        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
553    }
554
555    /**
556     * @param int $pageId the id of the page to load history for
557     * @param RevisionRecord|null $fromRev
558     * @return int the count
559     */
560    protected function getMinorCount( $pageId, RevisionRecord $fromRev = null ) {
561        $dbr = $this->dbProvider->getReplicaDatabase();
562        $queryBuilder = $dbr->newSelectQueryBuilder()
563            ->select( '1' )
564            ->from( 'revision' )
565            ->where( [
566                'rev_page' => $pageId,
567                'rev_minor_edit != 0',
568                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
569            ] )
570            ->limit( self::COUNT_LIMITS['minor'] + 1 ); // extra to detect truncation
571
572        if ( $fromRev ) {
573            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
574                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
575                'rev_id' => $fromRev->getId(),
576            ] ) );
577        }
578
579        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
580    }
581
582    /**
583     * @param int $pageId the id of the page to load history for
584     * @param RevisionRecord|null $fromRev
585     * @param RevisionRecord|null $toRev
586     * @return int the count
587     */
588    protected function getEditsCount(
589        $pageId,
590        RevisionRecord $fromRev = null,
591        RevisionRecord $toRev = null
592    ) {
593        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
594        return $this->revisionStore->countRevisionsBetween(
595            $pageId,
596            $fromRev,
597            $toRev,
598            self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
599        );
600    }
601
602    /**
603     * @param int $revId
604     * @return RevisionRecord
605     * @throws LocalizedHttpException
606     */
607    private function getRevisionOrThrow( $revId ) {
608        $rev = $this->revisionStore->getRevisionById( $revId );
609        if ( !$rev ) {
610            throw new LocalizedHttpException(
611                new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
612                404
613            );
614        }
615        return $rev;
616    }
617
618    /**
619     * Reorders revisions if they are present
620     * @param RevisionRecord|null $fromRev
621     * @param RevisionRecord|null $toRev
622     * @return array
623     * @phan-return array{0:RevisionRecord|null,1:RevisionRecord|null}
624     */
625    private function orderRevisions(
626        RevisionRecord $fromRev = null,
627        RevisionRecord $toRev = null
628    ) {
629        if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
630                ( $fromRev->getTimestamp() === $toRev->getTimestamp()
631                    && $fromRev->getId() > $toRev->getId() ) )
632        ) {
633            return [ $toRev, $fromRev ];
634        }
635        return [ $fromRev, $toRev ];
636    }
637
638    public function needsWriteAccess() {
639        return false;
640    }
641
642    public function getParamSettings() {
643        return [
644            'title' => [
645                self::PARAM_SOURCE => 'path',
646                ParamValidator::PARAM_TYPE => 'string',
647                ParamValidator::PARAM_REQUIRED => true,
648            ],
649            'type' => [
650                self::PARAM_SOURCE => 'path',
651                ParamValidator::PARAM_TYPE => array_merge(
652                    array_keys( self::COUNT_LIMITS ),
653                    array_keys( self::DEPRECATED_COUNT_TYPES )
654                ),
655                ParamValidator::PARAM_REQUIRED => true,
656            ],
657            'from' => [
658                self::PARAM_SOURCE => 'query',
659                ParamValidator::PARAM_TYPE => 'integer',
660                ParamValidator::PARAM_REQUIRED => false
661            ],
662            'to' => [
663                self::PARAM_SOURCE => 'query',
664                ParamValidator::PARAM_TYPE => 'integer',
665                ParamValidator::PARAM_REQUIRED => false
666            ]
667        ];
668    }
669}