Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.11% covered (danger)
49.11%
692 / 1409
30.56% covered (danger)
30.56%
22 / 72
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStore
49.11% covered (danger)
49.11%
692 / 1409
30.56% covered (danger)
30.56%
22 / 72
12973.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isReadOnly
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWikiId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDBConnectionRefForQueryFlags
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDBConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getPage
19.35% covered (danger)
19.35%
6 / 31
0.00% covered (danger)
0.00%
0 / 1
87.53
 wrapPage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 failOnNull
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 failOnEmpty
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 insertRevisionOn
98.57% covered (success)
98.57%
69 / 70
0.00% covered (danger)
0.00%
0 / 1
5
 updateSlotsOn
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 updateSlotsInternal
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 insertRevisionInternal
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 insertSlotOn
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 insertIpChangesRow
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 insertRevisionRowOn
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
72
 getBaseRevisionRow
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 storeContentBlob
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 insertSlotRowOn
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 insertContentRowOn
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 checkContent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 newNullRevision
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
3.04
 getRcIdIfUnpatrolled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getRecentChange
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 loadSlotContent
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
90
 getRevisionById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionByTitle
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 getRevisionByPageId
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 getRevisionByTimestamp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 loadSlotRecords
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 loadSlotRecordsFromDb
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 constructSlotRecords
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
 newRevisionSlots
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 newRevisionFromArchiveRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newRevisionFromRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newRevisionFromArchiveRowAndSlots
87.50% covered (warning)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
10.20
 newRevisionFromRowAndSlots
33.78% covered (danger)
33.78%
25 / 74
0.00% covered (danger)
0.00%
0 / 1
53.81
 ensureRevisionRowMatchesPage
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 newRevisionsFromBatch
86.47% covered (warning)
86.47%
115 / 133
0.00% covered (danger)
0.00%
0 / 1
33.38
 getSlotRowsForBatch
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
420
 getContentBlobsForBatch
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 newRevisionFromConds
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 loadRevisionFromConds
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 checkDatabaseDomain
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 fetchRevisionRowFromConds
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getQueryInfo
94.23% covered (success)
94.23%
49 / 52
0.00% covered (danger)
0.00%
0 / 1
4.00
 newSelectQueryBuilder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newArchiveSelectQueryBuilder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSlotsQueryInfo
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
4
 isRevisionRow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 getArchiveQueryInfo
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionSizes
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getRelativeRevision
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
110
 getPreviousRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNextRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousRevisionId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getTimestampFromId
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 countRevisionsByPageId
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 countRevisionsByTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 userWasLastToEdit
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 getKnownCurrentRevision
66.67% covered (warning)
66.67%
28 / 42
0.00% covered (danger)
0.00%
0 / 1
12.00
 getFirstRevision
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getRevisionRowCacheKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 assertRevisionParameter
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getRevisionLimitConditions
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 getRevisionIdsBetween
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 getAuthorsBetween
89.47% covered (warning)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
7.06
 countAuthorsBetween
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 countRevisionsBetween
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 findIdenticalRevision
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * Attribution notice: when this file was created, much of its content was taken
19 * from the Revision.php file as present in release 1.30. Refer to the history
20 * of that file for original authorship (that file was removed entirely in 1.37,
21 * but its history can still be found in prior versions of MediaWiki).
22 *
23 * @file
24 */
25
26namespace MediaWiki\Revision;
27
28use BagOStuff;
29use Content;
30use DBAccessObjectUtils;
31use FallbackContent;
32use IDBAccessObject;
33use InvalidArgumentException;
34use LogicException;
35use MediaWiki\CommentStore\CommentStore;
36use MediaWiki\CommentStore\CommentStoreComment;
37use MediaWiki\Content\IContentHandlerFactory;
38use MediaWiki\DAO\WikiAwareEntity;
39use MediaWiki\HookContainer\HookContainer;
40use MediaWiki\HookContainer\HookRunner;
41use MediaWiki\Linker\LinkTarget;
42use MediaWiki\Page\LegacyArticleIdAccess;
43use MediaWiki\Page\PageIdentity;
44use MediaWiki\Page\PageIdentityValue;
45use MediaWiki\Page\PageStore;
46use MediaWiki\Permissions\Authority;
47use MediaWiki\Storage\BadBlobException;
48use MediaWiki\Storage\BlobAccessException;
49use MediaWiki\Storage\BlobStore;
50use MediaWiki\Storage\NameTableAccessException;
51use MediaWiki\Storage\NameTableStore;
52use MediaWiki\Storage\RevisionSlotsUpdate;
53use MediaWiki\Storage\SqlBlobStore;
54use MediaWiki\Title\Title;
55use MediaWiki\Title\TitleFactory;
56use MediaWiki\User\ActorStore;
57use MediaWiki\User\UserIdentity;
58use MediaWiki\Utils\MWTimestamp;
59use MWException;
60use MWUnknownContentModelException;
61use Psr\Log\LoggerAwareInterface;
62use Psr\Log\LoggerInterface;
63use Psr\Log\NullLogger;
64use RecentChange;
65use RuntimeException;
66use StatusValue;
67use stdClass;
68use Traversable;
69use WANObjectCache;
70use Wikimedia\Assert\Assert;
71use Wikimedia\IPUtils;
72use Wikimedia\Rdbms\Database;
73use Wikimedia\Rdbms\IDatabase;
74use Wikimedia\Rdbms\ILoadBalancer;
75use Wikimedia\Rdbms\IReadableDatabase;
76use Wikimedia\Rdbms\IResultWrapper;
77use Wikimedia\Rdbms\Platform\ISQLPlatform;
78use Wikimedia\Rdbms\SelectQueryBuilder;
79
80/**
81 * Service for looking up page revisions.
82 *
83 * @since 1.31
84 * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore
85 *
86 * @note This was written to act as a drop-in replacement for the corresponding
87 *       static methods in the old Revision class (which was later removed in 1.37).
88 */
89class RevisionStore implements RevisionFactory, RevisionLookup, LoggerAwareInterface {
90
91    use LegacyArticleIdAccess;
92
93    public const ROW_CACHE_KEY = 'revision-row-1.29';
94
95    public const ORDER_OLDEST_TO_NEWEST = 'ASC';
96    public const ORDER_NEWEST_TO_OLDEST = 'DESC';
97
98    // Constants for get(...)Between methods
99    public const INCLUDE_OLD = 'include_old';
100    public const INCLUDE_NEW = 'include_new';
101    public const INCLUDE_BOTH = 'include_both';
102
103    /**
104     * @var SqlBlobStore
105     */
106    private $blobStore;
107
108    /**
109     * @var false|string
110     */
111    private $wikiId;
112
113    /**
114     * @var ILoadBalancer
115     */
116    private $loadBalancer;
117
118    /**
119     * @var WANObjectCache
120     */
121    private $cache;
122
123    /**
124     * @var BagOStuff
125     */
126    private $localCache;
127
128    /**
129     * @var CommentStore
130     */
131    private $commentStore;
132
133    /** @var ActorStore */
134    private $actorStore;
135
136    /**
137     * @var LoggerInterface
138     */
139    private $logger;
140
141    /**
142     * @var NameTableStore
143     */
144    private $contentModelStore;
145
146    /**
147     * @var NameTableStore
148     */
149    private $slotRoleStore;
150
151    /** @var SlotRoleRegistry */
152    private $slotRoleRegistry;
153
154    /** @var IContentHandlerFactory */
155    private $contentHandlerFactory;
156
157    /** @var HookRunner */
158    private $hookRunner;
159
160    /** @var PageStore */
161    private $pageStore;
162
163    /** @var TitleFactory */
164    private $titleFactory;
165
166    /**
167     * @param ILoadBalancer $loadBalancer
168     * @param SqlBlobStore $blobStore
169     * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
170     *        wiki's default instance even if $wikiId refers to a different wiki, since
171     *        makeGlobalKey() is used to constructed a key that allows cached revision rows from
172     *        the same database to be re-used between wikis. For example, enwiki and frwiki will
173     *        use the same cache keys for revision rows from the wikidatawiki database, regardless
174     *        of the cache's default key space.
175     * @param BagOStuff $localCache Another layer of cache, best to use APCu here.
176     * @param CommentStore $commentStore
177     * @param NameTableStore $contentModelStore
178     * @param NameTableStore $slotRoleStore
179     * @param SlotRoleRegistry $slotRoleRegistry
180     * @param ActorStore $actorStore
181     * @param IContentHandlerFactory $contentHandlerFactory
182     * @param PageStore $pageStore
183     * @param TitleFactory $titleFactory
184     * @param HookContainer $hookContainer
185     * @param false|string $wikiId Relevant wiki id or WikiAwareEntity::LOCAL for the current one
186     *
187     * @todo $blobStore should be allowed to be any BlobStore!
188     *
189     */
190    public function __construct(
191        ILoadBalancer $loadBalancer,
192        SqlBlobStore $blobStore,
193        WANObjectCache $cache,
194        BagOStuff $localCache,
195        CommentStore $commentStore,
196        NameTableStore $contentModelStore,
197        NameTableStore $slotRoleStore,
198        SlotRoleRegistry $slotRoleRegistry,
199        ActorStore $actorStore,
200        IContentHandlerFactory $contentHandlerFactory,
201        PageStore $pageStore,
202        TitleFactory $titleFactory,
203        HookContainer $hookContainer,
204        $wikiId = WikiAwareEntity::LOCAL
205    ) {
206        Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' );
207
208        $this->loadBalancer = $loadBalancer;
209        $this->blobStore = $blobStore;
210        $this->cache = $cache;
211        $this->localCache = $localCache;
212        $this->commentStore = $commentStore;
213        $this->contentModelStore = $contentModelStore;
214        $this->slotRoleStore = $slotRoleStore;
215        $this->slotRoleRegistry = $slotRoleRegistry;
216        $this->actorStore = $actorStore;
217        $this->wikiId = $wikiId;
218        $this->logger = new NullLogger();
219        $this->contentHandlerFactory = $contentHandlerFactory;
220        $this->pageStore = $pageStore;
221        $this->titleFactory = $titleFactory;
222        $this->hookRunner = new HookRunner( $hookContainer );
223    }
224
225    public function setLogger( LoggerInterface $logger ) {
226        $this->logger = $logger;
227    }
228
229    /**
230     * @return bool Whether the store is read-only
231     */
232    public function isReadOnly() {
233        return $this->blobStore->isReadOnly();
234    }
235
236    /**
237     * Get the ID of the wiki this revision belongs to.
238     *
239     * @return string|false The wiki's logical name, of false to indicate the local wiki.
240     */
241    public function getWikiId() {
242        return $this->wikiId;
243    }
244
245    /**
246     * @param int $queryFlags a bit field composed of READ_XXX flags
247     *
248     * @return IDatabase
249     */
250    private function getDBConnectionRefForQueryFlags( $queryFlags ) {
251        if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
252            return $this->getDBConnection( DB_PRIMARY );
253        } else {
254            return $this->getDBConnection( DB_REPLICA );
255        }
256    }
257
258    /**
259     * @param int $mode DB_PRIMARY or DB_REPLICA
260     * @param string|array $groups
261     * @return IDatabase
262     */
263    private function getDBConnection( $mode, $groups = [] ) {
264        return $this->loadBalancer->getConnection( $mode, $groups, $this->wikiId );
265    }
266
267    /**
268     * Determines the page Title based on the available information.
269     *
270     * MCR migration note: this corresponded to Revision::getTitle
271     *
272     * @deprecated since 1.36, Use RevisionRecord::getPage() instead.
273     * @note The resulting Title object will be misleading if the RevisionStore is not
274     *        for the local wiki.
275     *
276     * @param int|null $pageId
277     * @param int|null $revId
278     * @param int $queryFlags
279     *
280     * @return Title
281     * @throws RevisionAccessException
282     */
283    public function getTitle( $pageId, $revId, $queryFlags = IDBAccessObject::READ_NORMAL ) {
284        // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069
285        if ( $this->wikiId !== WikiAwareEntity::LOCAL ) {
286            wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' );
287        }
288
289        $page = $this->getPage( $pageId, $revId, $queryFlags );
290        return $this->titleFactory->newFromPageIdentity( $page );
291    }
292
293    /**
294     * Determines the page based on the available information.
295     *
296     * @param int|null $pageId
297     * @param int|null $revId
298     * @param int $queryFlags
299     *
300     * @return PageIdentity
301     * @throws RevisionAccessException
302     */
303    private function getPage( ?int $pageId, ?int $revId, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
304        if ( !$pageId && !$revId ) {
305            throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
306        }
307
308        // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
309        // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
310        if ( DBAccessObjectUtils::hasFlags( $queryFlags, IDBAccessObject::READ_LATEST_IMMUTABLE ) ) {
311            $queryFlags = IDBAccessObject::READ_NORMAL;
312        }
313
314        // Loading by ID is best
315        if ( $pageId !== null && $pageId > 0 ) {
316            $page = $this->pageStore->getPageById( $pageId, $queryFlags );
317            if ( $page ) {
318                return $this->wrapPage( $page );
319            }
320        }
321
322        // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
323        if ( $revId !== null && $revId > 0 ) {
324            $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags )
325                ->join( 'revision', null, 'page_id=rev_page' )
326                ->conds( [ 'rev_id' => $revId ] )
327                ->caller( __METHOD__ );
328
329            $page = $pageQuery->fetchPageRecord();
330            if ( $page ) {
331                return $this->wrapPage( $page );
332            }
333        }
334
335        // If we still don't have a title, fallback to primary DB if that wasn't already happening.
336        if ( $queryFlags === IDBAccessObject::READ_NORMAL ) {
337            $title = $this->getPage( $pageId, $revId, IDBAccessObject::READ_LATEST );
338            if ( $title ) {
339                $this->logger->info(
340                    __METHOD__ . ' fell back to READ_LATEST and got a Title.',
341                    [ 'exception' => new RuntimeException() ]
342                );
343                return $title;
344            }
345        }
346
347        throw new RevisionAccessException(
348            'Could not determine title for page ID {page_id} and revision ID {rev_id}',
349            [
350                'page_id' => $pageId,
351                'rev_id' => $revId,
352            ]
353        );
354    }
355
356    /**
357     * @param PageIdentity $page
358     *
359     * @return PageIdentity
360     */
361    private function wrapPage( PageIdentity $page ): PageIdentity {
362        if ( $this->wikiId === WikiAwareEntity::LOCAL ) {
363            // NOTE: since there is still a lot of code that needs a full Title,
364            //       and uses Title::castFromPageIdentity() to get one, it's beneficial
365            //       to create a Title right away if we can, so we don't have to convert
366            //       over and over later on.
367            //       When there is less need to convert to Title, this special case can
368            //       be removed.
369            return $this->titleFactory->newFromPageIdentity( $page );
370        } else {
371            return $page;
372        }
373    }
374
375    /**
376     * @param mixed $value
377     * @param string $name
378     *
379     * @throws IncompleteRevisionException if $value is null
380     * @return mixed $value, if $value is not null
381     */
382    private function failOnNull( $value, $name ) {
383        if ( $value === null ) {
384            throw new IncompleteRevisionException(
385                "$name must not be " . var_export( $value, true ) . "!"
386            );
387        }
388
389        return $value;
390    }
391
392    /**
393     * @param mixed $value
394     * @param string $name
395     *
396     * @throws IncompleteRevisionException if $value is empty
397     * @return mixed $value, if $value is not null
398     */
399    private function failOnEmpty( $value, $name ) {
400        if ( $value === null || $value === 0 || $value === '' ) {
401            throw new IncompleteRevisionException(
402                "$name must not be " . var_export( $value, true ) . "!"
403            );
404        }
405
406        return $value;
407    }
408
409    /**
410     * Insert a new revision into the database, returning the new revision record
411     * on success and dies horribly on failure.
412     *
413     * MCR migration note: this replaced Revision::insertOn
414     *
415     * @param RevisionRecord $rev
416     * @param IDatabase $dbw (primary connection)
417     *
418     * @return RevisionRecord the new revision record.
419     */
420    public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
421        // TODO: pass in a DBTransactionContext instead of a database connection.
422        $this->checkDatabaseDomain( $dbw );
423
424        $slotRoles = $rev->getSlotRoles();
425
426        // Make sure the main slot is always provided throughout migration
427        if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
428            throw new IncompleteRevisionException(
429                'main slot must be provided'
430            );
431        }
432
433        // Checks
434        $this->failOnNull( $rev->getSize(), 'size field' );
435        $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
436        $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
437        $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
438        $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
439        $this->failOnNull( $user->getId(), 'user field' );
440        $this->failOnEmpty( $user->getName(), 'user_text field' );
441
442        if ( !$rev->isReadyForInsertion() ) {
443            // This is here for future-proofing. At the time this check being added, it
444            // was redundant to the individual checks above.
445            throw new IncompleteRevisionException( 'Revision is incomplete' );
446        }
447
448        if ( $slotRoles == [ SlotRecord::MAIN ] ) {
449            // T239717: If the main slot is the only slot, make sure the revision's nominal size
450            // and hash match the main slot's nominal size and hash.
451            $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
452            Assert::precondition(
453                $mainSlot->getSize() === $rev->getSize(),
454                'The revisions\'s size must match the main slot\'s size (see T239717)'
455            );
456            Assert::precondition(
457                $mainSlot->getSha1() === $rev->getSha1(),
458                'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)'
459            );
460        }
461
462        $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early
463
464        $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev );
465
466        /** @var RevisionRecord $rev */
467        $rev = $dbw->doAtomicSection(
468            __METHOD__,
469            function ( IDatabase $dbw, $fname ) use (
470                $rev,
471                $user,
472                $comment,
473                $pageId,
474                $parentId
475            ) {
476                return $this->insertRevisionInternal(
477                    $rev,
478                    $dbw,
479                    $user,
480                    $comment,
481                    $rev->getPage(),
482                    $pageId,
483                    $parentId
484                );
485            }
486        );
487
488        Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' );
489        Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' );
490        Assert::postcondition(
491            $rev->getComment( RevisionRecord::RAW ) !== null,
492            'revision must have a comment'
493        );
494        Assert::postcondition(
495            $rev->getUser( RevisionRecord::RAW ) !== null,
496            'revision must have a user'
497        );
498
499        // Trigger exception if the main slot is missing.
500        // Technically, this could go away after MCR migration: while
501        // calling code may require a main slot to exist, RevisionStore
502        // really should not know or care about that requirement.
503        $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
504
505        foreach ( $slotRoles as $role ) {
506            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
507            Assert::postcondition(
508                $slot->getContent() !== null,
509                $role . ' slot must have content'
510            );
511            Assert::postcondition(
512                $slot->hasRevision(),
513                $role . ' slot must have a revision associated'
514            );
515        }
516
517        $this->hookRunner->onRevisionRecordInserted( $rev );
518
519        return $rev;
520    }
521
522    /**
523     * Update derived slots in an existing revision into the database, returning the modified
524     * slots on success.
525     *
526     * @param RevisionRecord $revision After this method returns, the $revision object will be
527     *                                 obsolete in that it does not have the new slots.
528     * @param RevisionSlotsUpdate $revisionSlotsUpdate
529     * @param IDatabase $dbw (primary connection)
530     *
531     * @return SlotRecord[] the new slot records.
532     * @internal
533     */
534    public function updateSlotsOn(
535        RevisionRecord $revision,
536        RevisionSlotsUpdate $revisionSlotsUpdate,
537        IDatabase $dbw
538    ): array {
539        $this->checkDatabaseDomain( $dbw );
540
541        // Make sure all modified and removed slots are derived slots
542        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
543            Assert::precondition(
544                $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(),
545                'Trying to modify a slot that is not derived'
546            );
547        }
548        foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) {
549            $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
550            Assert::precondition(
551                $isDerived,
552                'Trying to remove a slot that is not derived'
553            );
554            throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' );
555        }
556
557        /** @var SlotRecord[] $slotRecords */
558        $slotRecords = $dbw->doAtomicSection(
559            __METHOD__,
560            function ( IDatabase $dbw, $fname ) use (
561                $revision,
562                $revisionSlotsUpdate
563            ) {
564                return $this->updateSlotsInternal(
565                    $revision,
566                    $revisionSlotsUpdate,
567                    $dbw
568                );
569            }
570        );
571
572        foreach ( $slotRecords as $role => $slot ) {
573            Assert::postcondition(
574                $slot->getContent() !== null,
575                $role . ' slot must have content'
576            );
577            Assert::postcondition(
578                $slot->hasRevision(),
579                $role . ' slot must have a revision associated'
580            );
581        }
582
583        return $slotRecords;
584    }
585
586    /**
587     * @param RevisionRecord $revision
588     * @param RevisionSlotsUpdate $revisionSlotsUpdate
589     * @param IDatabase $dbw
590     * @return SlotRecord[]
591     */
592    private function updateSlotsInternal(
593        RevisionRecord $revision,
594        RevisionSlotsUpdate $revisionSlotsUpdate,
595        IDatabase $dbw
596    ): array {
597        $page = $revision->getPage();
598        $revId = $revision->getId( $this->wikiId );
599        $blobHints = [
600            BlobStore::PAGE_HINT => $page->getId( $this->wikiId ),
601            BlobStore::REVISION_HINT => $revId,
602            BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ),
603        ];
604
605        $newSlots = [];
606        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
607            $slot = $revisionSlotsUpdate->getModifiedSlot( $role );
608            $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints );
609        }
610
611        return $newSlots;
612    }
613
614    private function insertRevisionInternal(
615        RevisionRecord $rev,
616        IDatabase $dbw,
617        UserIdentity $user,
618        CommentStoreComment $comment,
619        PageIdentity $page,
620        $pageId,
621        $parentId
622    ) {
623        $slotRoles = $rev->getSlotRoles();
624
625        $revisionRow = $this->insertRevisionRowOn(
626            $dbw,
627            $rev,
628            $parentId
629        );
630
631        $revisionId = $revisionRow['rev_id'];
632
633        $blobHints = [
634            BlobStore::PAGE_HINT => $pageId,
635            BlobStore::REVISION_HINT => $revisionId,
636            BlobStore::PARENT_HINT => $parentId,
637        ];
638
639        $newSlots = [];
640        foreach ( $slotRoles as $role ) {
641            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
642
643            // If the SlotRecord already has a revision ID set, this means it already exists
644            // in the database, and should already belong to the current revision.
645            // However, a slot may already have a revision, but no content ID, if the slot
646            // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
647            // mode, and the respective archive row was not yet migrated to the new schema.
648            // In that case, a new slot row (and content row) must be inserted even during
649            // undeletion.
650            if ( $slot->hasRevision() && $slot->hasContentId() ) {
651                // TODO: properly abort transaction if the assertion fails!
652                Assert::parameter(
653                    $slot->getRevision() === $revisionId,
654                    'slot role ' . $slot->getRole(),
655                    'Existing slot should belong to revision '
656                    . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
657                );
658
659                // Slot exists, nothing to do, move along.
660                // This happens when restoring archived revisions.
661
662                $newSlots[$role] = $slot;
663            } else {
664                $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints );
665            }
666        }
667
668        $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
669
670        $rev = new RevisionStoreRecord(
671            $page,
672            $user,
673            $comment,
674            (object)$revisionRow,
675            new RevisionSlots( $newSlots ),
676            $this->wikiId
677        );
678
679        return $rev;
680    }
681
682    /**
683     * @param IDatabase $dbw
684     * @param int $revisionId
685     * @param SlotRecord $protoSlot
686     * @param PageIdentity $page
687     * @param array $blobHints See the BlobStore::XXX_HINT constants
688     * @return SlotRecord
689     */
690    private function insertSlotOn(
691        IDatabase $dbw,
692        $revisionId,
693        SlotRecord $protoSlot,
694        PageIdentity $page,
695        array $blobHints = []
696    ) {
697        if ( $protoSlot->hasAddress() ) {
698            $blobAddress = $protoSlot->getAddress();
699        } else {
700            $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints );
701        }
702
703        if ( $protoSlot->hasContentId() ) {
704            $contentId = $protoSlot->getContentId();
705        } else {
706            $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
707        }
708
709        $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
710
711        return SlotRecord::newSaved(
712            $revisionId,
713            $contentId,
714            $blobAddress,
715            $protoSlot
716        );
717    }
718
719    /**
720     * Insert IP revision into ip_changes for use when querying for a range.
721     * @param IDatabase $dbw
722     * @param UserIdentity $user
723     * @param RevisionRecord $rev
724     * @param int $revisionId
725     */
726    private function insertIpChangesRow(
727        IDatabase $dbw,
728        UserIdentity $user,
729        RevisionRecord $rev,
730        $revisionId
731    ) {
732        if ( !$user->isRegistered() && IPUtils::isValid( $user->getName() ) ) {
733            $dbw->newInsertQueryBuilder()
734                ->insertInto( 'ip_changes' )
735                ->row( [
736                    'ipc_rev_id'        => $revisionId,
737                    'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
738                    'ipc_hex'           => IPUtils::toHex( $user->getName() ),
739                ] )
740                ->caller( __METHOD__ )->execute();
741
742        }
743    }
744
745    /**
746     * @param IDatabase $dbw
747     * @param RevisionRecord $rev
748     * @param int $parentId
749     *
750     * @return array a revision table row
751     *
752     * @throws MWException
753     * @throws MWUnknownContentModelException
754     */
755    private function insertRevisionRowOn(
756        IDatabase $dbw,
757        RevisionRecord $rev,
758        $parentId
759    ) {
760        $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId );
761
762        $revisionRow += $this->commentStore->insert(
763            $dbw,
764            'rev_comment',
765            $rev->getComment( RevisionRecord::RAW )
766        );
767
768        $dbw->newInsertQueryBuilder()
769            ->insertInto( 'revision' )
770            ->row( $revisionRow )
771            ->caller( __METHOD__ )->execute();
772
773        if ( !isset( $revisionRow['rev_id'] ) ) {
774            // only if auto-increment was used
775            $revisionRow['rev_id'] = intval( $dbw->insertId() );
776
777            if ( $dbw->getType() === 'mysql' ) {
778                // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
779                // auto-increment value to disk, so on server restart it might reuse IDs from deleted
780                // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
781
782                $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
783                $table = 'archive';
784                $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
785                if ( $maxRevId2 >= $maxRevId ) {
786                    $maxRevId = $maxRevId2;
787                    $table = 'slots';
788                }
789
790                if ( $maxRevId >= $revisionRow['rev_id'] ) {
791                    $this->logger->debug(
792                        '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
793                            . ' Trying to fix it.',
794                        [
795                            'revid' => $revisionRow['rev_id'],
796                            'table' => $table,
797                            'maxrevid' => $maxRevId,
798                        ]
799                    );
800
801                    if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
802                        throw new MWException( 'Failed to get database lock for T202032' );
803                    }
804                    $fname = __METHOD__;
805                    $dbw->onTransactionResolution(
806                        static function ( $trigger, IDatabase $dbw ) use ( $fname ) {
807                            $dbw->unlock( 'fix-for-T202032', $fname );
808                        },
809                        __METHOD__
810                    );
811
812                    $dbw->newDeleteQueryBuilder()
813                        ->deleteFrom( 'revision' )
814                        ->where( [ 'rev_id' => $revisionRow['rev_id'] ] )
815                        ->caller( __METHOD__ )->execute();
816
817                    // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
818                    // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
819                    // inserts too, though, at least on MariaDB 10.1.29.
820                    //
821                    // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
822                    // transactions in this code path thanks to the row lock from the original ->insert() above.
823                    //
824                    // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
825                    // that's for non-MySQL DBs.
826                    $row1 = $dbw->query(
827                        $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE',
828                        __METHOD__
829                    )->fetchObject();
830
831                    $row2 = $dbw->query(
832                        $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
833                            . ' FOR UPDATE',
834                        __METHOD__
835                    )->fetchObject();
836
837                    $maxRevId = max(
838                        $maxRevId,
839                        $row1 ? intval( $row1->v ) : 0,
840                        $row2 ? intval( $row2->v ) : 0
841                    );
842
843                    // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
844                    // transactions will throw a duplicate key error here. It doesn't seem worth trying
845                    // to avoid that.
846                    $revisionRow['rev_id'] = $maxRevId + 1;
847                    $dbw->newInsertQueryBuilder()
848                        ->insertInto( 'revision' )
849                        ->row( $revisionRow )
850                        ->caller( __METHOD__ )->execute();
851                }
852            }
853        }
854
855        return $revisionRow;
856    }
857
858    /**
859     * @param IDatabase $dbw
860     * @param RevisionRecord $rev
861     * @param int $parentId
862     *
863     * @return array a revision table row
864     */
865    private function getBaseRevisionRow(
866        IDatabase $dbw,
867        RevisionRecord $rev,
868        $parentId
869    ) {
870        // Record the edit in revisions
871        $revisionRow = [
872            'rev_page'       => $rev->getPageId( $this->wikiId ),
873            'rev_parent_id'  => $parentId,
874            'rev_actor'      => $this->actorStore->acquireActorId(
875                $rev->getUser( RevisionRecord::RAW ),
876                $dbw
877            ),
878            'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
879            'rev_timestamp'  => $dbw->timestamp( $rev->getTimestamp() ),
880            'rev_deleted'    => $rev->getVisibility(),
881            'rev_len'        => $rev->getSize(),
882            'rev_sha1'       => $rev->getSha1(),
883        ];
884
885        if ( $rev->getId( $this->wikiId ) !== null ) {
886            // Needed to restore revisions with their original ID
887            $revisionRow['rev_id'] = $rev->getId( $this->wikiId );
888        }
889
890        return $revisionRow;
891    }
892
893    /**
894     * @param SlotRecord $slot
895     * @param PageIdentity $page
896     * @param array $blobHints See the BlobStore::XXX_HINT constants
897     *
898     * @throws MWException
899     * @return string the blob address
900     */
901    private function storeContentBlob(
902        SlotRecord $slot,
903        PageIdentity $page,
904        array $blobHints = []
905    ) {
906        $content = $slot->getContent();
907        $format = $content->getDefaultFormat();
908        $model = $content->getModel();
909
910        $this->checkContent( $content, $page, $slot->getRole() );
911
912        return $this->blobStore->storeBlob(
913            $content->serialize( $format ),
914            // These hints "leak" some information from the higher abstraction layer to
915            // low level storage to allow for optimization.
916            array_merge(
917                $blobHints,
918                [
919                    BlobStore::DESIGNATION_HINT => 'page-content',
920                    BlobStore::ROLE_HINT => $slot->getRole(),
921                    BlobStore::SHA1_HINT => $slot->getSha1(),
922                    BlobStore::MODEL_HINT => $model,
923                    BlobStore::FORMAT_HINT => $format,
924                ]
925            )
926        );
927    }
928
929    /**
930     * @param SlotRecord $slot
931     * @param IDatabase $dbw
932     * @param int $revisionId
933     * @param int $contentId
934     */
935    private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
936        $dbw->newInsertQueryBuilder()
937            ->insertInto( 'slots' )
938            ->row( [
939                'slot_revision_id' => $revisionId,
940                'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
941                'slot_content_id' => $contentId,
942                // If the slot has a specific origin use that ID, otherwise use the ID of the revision
943                // that we just inserted.
944                'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
945            ] )
946            ->caller( __METHOD__ )->execute();
947    }
948
949    /**
950     * @param SlotRecord $slot
951     * @param IDatabase $dbw
952     * @param string $blobAddress
953     * @return int content row ID
954     */
955    private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
956        $dbw->newInsertQueryBuilder()
957            ->insertInto( 'content' )
958            ->row( [
959                'content_size' => $slot->getSize(),
960                'content_sha1' => $slot->getSha1(),
961                'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
962                'content_address' => $blobAddress,
963            ] )
964            ->caller( __METHOD__ )->execute();
965        return intval( $dbw->insertId() );
966    }
967
968    /**
969     * MCR migration note: this corresponded to Revision::checkContentModel
970     *
971     * @param Content $content
972     * @param PageIdentity $page
973     * @param string $role
974     *
975     * @throws MWException
976     * @throws MWUnknownContentModelException
977     */
978    private function checkContent( Content $content, PageIdentity $page, string $role ) {
979        // Note: may return null for revisions that have not yet been inserted
980
981        $model = $content->getModel();
982        $format = $content->getDefaultFormat();
983        $handler = $content->getContentHandler();
984
985        if ( !$handler->isSupportedFormat( $format ) ) {
986            throw new MWException(
987                "Can't use format $format with content model $model on $page role $role"
988            );
989        }
990
991        if ( !$content->isValid() ) {
992            throw new MWException(
993                "New content for $page role $role is not valid! Content model is $model"
994            );
995        }
996    }
997
998    /**
999     * Create a new null-revision for insertion into a page's
1000     * history. This will not re-save the text, but simply refer
1001     * to the text from the previous version.
1002     *
1003     * Such revisions can for instance identify page rename
1004     * operations and other such meta-modifications.
1005     *
1006     * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
1007     * to prevent a new revision from being inserted before the null revision has been written
1008     * to the database.
1009     *
1010     * MCR migration note: this replaced Revision::newNullRevision
1011     *
1012     * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
1013     * (or go away).
1014     *
1015     * @param IDatabase $dbw used for obtaining the lock on the page table row
1016     * @param PageIdentity $page the page to read from
1017     * @param CommentStoreComment $comment RevisionRecord's summary
1018     * @param bool $minor Whether the revision should be considered as minor
1019     * @param UserIdentity $user The user to attribute the revision to
1020     *
1021     * @return RevisionRecord|null RevisionRecord or null on error
1022     */
1023    public function newNullRevision(
1024        IDatabase $dbw,
1025        PageIdentity $page,
1026        CommentStoreComment $comment,
1027        $minor,
1028        UserIdentity $user
1029    ) {
1030        $this->checkDatabaseDomain( $dbw );
1031
1032        $pageId = $this->getArticleId( $page );
1033
1034        // T51581: Lock the page table row to ensure no other process
1035        // is adding a revision to the page at the same time.
1036        // Avoid locking extra tables, compare T191892.
1037        $pageLatest = $dbw->newSelectQueryBuilder()
1038            ->select( 'page_latest' )
1039            ->forUpdate()
1040            ->from( 'page' )
1041            ->where( [ 'page_id' => $pageId ] )
1042            ->caller( __METHOD__ )->fetchField();
1043
1044        if ( !$pageLatest ) {
1045            $msg = 'T235589: Failed to select table row during null revision creation' .
1046                " Page id '$pageId' does not exist.";
1047            $this->logger->error(
1048                $msg,
1049                [ 'exception' => new RuntimeException( $msg ) ]
1050            );
1051
1052            return null;
1053        }
1054
1055        // Fetch the actual revision row from primary DB, without locking all extra tables.
1056        $oldRevision = $this->loadRevisionFromConds(
1057            $dbw,
1058            [ 'rev_id' => intval( $pageLatest ) ],
1059            IDBAccessObject::READ_LATEST,
1060            $page
1061        );
1062
1063        if ( !$oldRevision ) {
1064            $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1065            $this->logger->error(
1066                $msg,
1067                [ 'exception' => new RuntimeException( $msg ) ]
1068            );
1069            return null;
1070        }
1071
1072        // Construct the new revision
1073        $timestamp = MWTimestamp::now( TS_MW );
1074        $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1075
1076        $newRevision->setComment( $comment );
1077        $newRevision->setUser( $user );
1078        $newRevision->setTimestamp( $timestamp );
1079        $newRevision->setMinorEdit( $minor );
1080
1081        return $newRevision;
1082    }
1083
1084    /**
1085     * MCR migration note: this replaced Revision::isUnpatrolled
1086     *
1087     * @todo This is overly specific, so move or kill this method.
1088     *
1089     * @param RevisionRecord $rev
1090     *
1091     * @return int Rcid of the unpatrolled row, zero if there isn't one
1092     */
1093    public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1094        $rc = $this->getRecentChange( $rev );
1095        if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1096            return $rc->getAttribute( 'rc_id' );
1097        } else {
1098            return 0;
1099        }
1100    }
1101
1102    /**
1103     * Get the RC object belonging to the current revision, if there's one
1104     *
1105     * MCR migration note: this replaced Revision::getRecentChange
1106     *
1107     * @todo move this somewhere else?
1108     *
1109     * @param RevisionRecord $rev
1110     * @param int $flags (optional) $flags include:
1111     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1112     *
1113     * @return null|RecentChange
1114     */
1115    public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1116        if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
1117            $dbType = DB_PRIMARY;
1118        } else {
1119            $dbType = DB_REPLICA;
1120        }
1121
1122        $rc = RecentChange::newFromConds(
1123            [
1124                'rc_this_oldid' => $rev->getId( $this->wikiId ),
1125                // rc_this_oldid does not have to be unique,
1126                // in particular, it is shared with categorization
1127                // changes. Prefer the original change because callers
1128                // often expect a change for patrolling.
1129                'rc_type' => [ RC_EDIT, RC_NEW, RC_LOG ],
1130            ],
1131            __METHOD__,
1132            $dbType
1133        );
1134
1135        // XXX: cache this locally? Glue it to the RevisionRecord?
1136        return $rc;
1137    }
1138
1139    /**
1140     * Loads a Content object based on a slot row.
1141     *
1142     * This method does not call $slot->getContent(), and may be used as a callback
1143     * called by $slot->getContent().
1144     *
1145     * MCR migration note: this roughly corresponded to Revision::getContentInternal
1146     *
1147     * @param SlotRecord $slot The SlotRecord to load content for
1148     * @param string|null $blobData The content blob, in the form indicated by $blobFlags
1149     * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
1150     *        Use null if no processing should happen. That is in contrast to the empty string,
1151     *        which causes the blob to be decoded according to the configured legacy encoding.
1152     * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
1153     * @param int $queryFlags
1154     *
1155     * @throws RevisionAccessException
1156     * @return Content
1157     */
1158    private function loadSlotContent(
1159        SlotRecord $slot,
1160        ?string $blobData = null,
1161        ?string $blobFlags = null,
1162        ?string $blobFormat = null,
1163        int $queryFlags = 0
1164    ) {
1165        if ( $blobData !== null ) {
1166            $blobAddress = $slot->hasAddress() ? $slot->getAddress() : null;
1167
1168            if ( $blobFlags === null ) {
1169                // No blob flags, so use the blob verbatim.
1170                $data = $blobData;
1171            } else {
1172                try {
1173                    $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress );
1174                } catch ( BadBlobException $e ) {
1175                    throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1176                }
1177
1178                if ( $data === false ) {
1179                    throw new RevisionAccessException(
1180                        'Failed to expand blob data using flags {flags} (key: {cache_key})',
1181                        [
1182                            'flags' => $blobFlags,
1183                            'cache_key' => $blobAddress,
1184                        ]
1185                    );
1186                }
1187            }
1188
1189        } else {
1190            $address = $slot->getAddress();
1191            try {
1192                $data = $this->blobStore->getBlob( $address, $queryFlags );
1193            } catch ( BadBlobException $e ) {
1194                throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1195            } catch ( BlobAccessException $e ) {
1196                throw new RevisionAccessException(
1197                    'Failed to load data blob from {address} for revision {revision}. '
1198                        . 'If this problem persist, use the findBadBlobs maintenance script '
1199                        . 'to investigate the issue and mark bad blobs.',
1200                    [ 'address' => $e->getMessage(), 'revision' => $slot->getRevision() ],
1201                    0,
1202                    $e
1203                );
1204            }
1205        }
1206
1207        $model = $slot->getModel();
1208
1209        // If the content model is not known, don't fail here (T220594, T220793, T228921)
1210        if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) {
1211            $this->logger->warning(
1212                "Undefined content model '$model', falling back to FallbackContent",
1213                [
1214                    'content_address' => $slot->getAddress(),
1215                    'rev_id' => $slot->getRevision(),
1216                    'role_name' => $slot->getRole(),
1217                    'model_name' => $model,
1218                    'exception' => new RuntimeException()
1219                ]
1220            );
1221
1222            return new FallbackContent( $data, $model );
1223        }
1224
1225        return $this->contentHandlerFactory
1226            ->getContentHandler( $model )
1227            ->unserializeContent( $data, $blobFormat );
1228    }
1229
1230    /**
1231     * Load a page revision from a given revision ID number.
1232     * Returns null if no such revision can be found.
1233     *
1234     * MCR migration note: this replaced Revision::newFromId
1235     *
1236     * $flags include:
1237     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1238     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1239     *
1240     * @param int $id
1241     * @param int $flags (optional)
1242     * @param PageIdentity|null $page The page the revision belongs to.
1243     *        Providing the page may improve performance.
1244     *
1245     * @return RevisionRecord|null
1246     */
1247    public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) {
1248        return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page );
1249    }
1250
1251    /**
1252     * Load either the current, or a specified, revision
1253     * that's attached to a given link target. If not attached
1254     * to that link target, will return null.
1255     *
1256     * MCR migration note: this replaced Revision::newFromTitle
1257     *
1258     * $flags include:
1259     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1260     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1261     *
1262     * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36
1263     * @param int $revId (optional)
1264     * @param int $flags Bitfield (optional)
1265     * @return RevisionRecord|null
1266     */
1267    public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) {
1268        $conds = [
1269            'page_namespace' => $page->getNamespace(),
1270            'page_title' => $page->getDBkey()
1271        ];
1272
1273        if ( $page instanceof LinkTarget ) {
1274            // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1275            $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1276        }
1277
1278        if ( $revId ) {
1279            // Use the specified revision ID.
1280            // Note that we use newRevisionFromConds here because we want to retry
1281            // and fall back to primary DB if the page is not found on a replica.
1282            // Since the caller supplied a revision ID, we are pretty sure the revision is
1283            // supposed to exist, so we should try hard to find it.
1284            $conds['rev_id'] = $revId;
1285            return $this->newRevisionFromConds( $conds, $flags, $page );
1286        } else {
1287            // Use a join to get the latest revision.
1288            // Note that we don't use newRevisionFromConds here because we don't want to retry
1289            // and fall back to primary DB. The assumption is that we only want to force the fallback
1290            // if we are quite sure the revision exists because the caller supplied a revision ID.
1291            // If the page isn't found at all on a replica, it probably simply does not exist.
1292            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1293            $conds[] = 'rev_id=page_latest';
1294            return $this->loadRevisionFromConds( $db, $conds, $flags, $page );
1295        }
1296    }
1297
1298    /**
1299     * Load either the current, or a specified, revision
1300     * that's attached to a given page ID.
1301     * Returns null if no such revision can be found.
1302     *
1303     * MCR migration note: this replaced Revision::newFromPageId
1304     *
1305     * $flags include:
1306     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB (since 1.20)
1307     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1308     *
1309     * @param int $pageId
1310     * @param int $revId (optional)
1311     * @param int $flags Bitfield (optional)
1312     * @return RevisionRecord|null
1313     */
1314    public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1315        $conds = [ 'page_id' => $pageId ];
1316        if ( $revId ) {
1317            // Use the specified revision ID.
1318            // Note that we use newRevisionFromConds here because we want to retry
1319            // and fall back to primary DB if the page is not found on a replica.
1320            // Since the caller supplied a revision ID, we are pretty sure the revision is
1321            // supposed to exist, so we should try hard to find it.
1322            $conds['rev_id'] = $revId;
1323            return $this->newRevisionFromConds( $conds, $flags );
1324        } else {
1325            // Use a join to get the latest revision.
1326            // Note that we don't use newRevisionFromConds here because we don't want to retry
1327            // and fall back to primary DB. The assumption is that we only want to force the fallback
1328            // if we are quite sure the revision exists because the caller supplied a revision ID.
1329            // If the page isn't found at all on a replica, it probably simply does not exist.
1330            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1331
1332            $conds[] = 'rev_id=page_latest';
1333
1334            return $this->loadRevisionFromConds( $db, $conds, $flags );
1335        }
1336    }
1337
1338    /**
1339     * Load the revision for the given title with the given timestamp.
1340     * WARNING: Timestamps may in some circumstances not be unique,
1341     * so this isn't the best key to use.
1342     *
1343     * MCR migration note: this replaced Revision::loadFromTimestamp
1344     *
1345     * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36
1346     * @param string $timestamp
1347     * @param int $flags Bitfield (optional) include:
1348     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1349     *      IDBAccessObject::READ_LOCKING: Select & lock the data from the primary DB
1350     *      Default: IDBAccessObject::READ_NORMAL
1351     * @return RevisionRecord|null
1352     */
1353    public function getRevisionByTimestamp(
1354        $page,
1355        string $timestamp,
1356        int $flags = IDBAccessObject::READ_NORMAL
1357    ): ?RevisionRecord {
1358        if ( $page instanceof LinkTarget ) {
1359            // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1360            $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1361        }
1362        $db = $this->getDBConnectionRefForQueryFlags( $flags );
1363        return $this->newRevisionFromConds(
1364            [
1365                'rev_timestamp' => $db->timestamp( $timestamp ),
1366                'page_namespace' => $page->getNamespace(),
1367                'page_title' => $page->getDBkey()
1368            ],
1369            $flags,
1370            $page
1371        );
1372    }
1373
1374    /**
1375     * @param int $revId The revision to load slots for.
1376     * @param int $queryFlags
1377     * @param PageIdentity $page
1378     *
1379     * @return SlotRecord[]
1380     */
1381    private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) {
1382        // TODO: Find a way to add NS_MODULE from Scribunto here
1383        if ( $page->getNamespace() !== NS_TEMPLATE ) {
1384            $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1385            return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1386        }
1387
1388        // TODO: These caches should not be needed. See T297147#7563670
1389        $res = $this->localCache->getWithSetCallback(
1390            $this->localCache->makeKey(
1391                'revision-slots',
1392                $page->getWikiId(),
1393                $page->getId( $page->getWikiId() ),
1394                $revId
1395            ),
1396            $this->localCache::TTL_HOUR,
1397            function () use ( $revId, $queryFlags, $page ) {
1398                return $this->cache->getWithSetCallback(
1399                    $this->cache->makeKey(
1400                        'revision-slots',
1401                        $page->getWikiId(),
1402                        $page->getId( $page->getWikiId() ),
1403                        $revId
1404                    ),
1405                    WANObjectCache::TTL_DAY,
1406                    function () use ( $revId, $queryFlags, $page ) {
1407                        $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1408                        if ( !$res ) {
1409                            // Avoid caching
1410                            return false;
1411                        }
1412                        return $res;
1413                    }
1414                );
1415            }
1416        );
1417        if ( !$res ) {
1418            $res = [];
1419        }
1420
1421        return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1422    }
1423
1424    private function loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page ): array {
1425        $revQuery = $this->getSlotsQueryInfo( [ 'content' ] );
1426
1427        [ $dbMode, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $queryFlags );
1428        $db = $this->getDBConnection( $dbMode );
1429
1430        $res = $db->select(
1431            $revQuery['tables'],
1432            $revQuery['fields'],
1433            [
1434                'slot_revision_id' => $revId,
1435            ],
1436            __METHOD__,
1437            $dbOptions,
1438            $revQuery['joins']
1439        );
1440
1441        if ( !$res->numRows() && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1442            // If we found no slots, try looking on the primary database (T212428, T252156)
1443            $this->logger->info(
1444                __METHOD__ . ' falling back to READ_LATEST.',
1445                [
1446                    'revid' => $revId,
1447                    'exception' => new RuntimeException(),
1448                ]
1449            );
1450            return $this->loadSlotRecordsFromDb(
1451                $revId,
1452                $queryFlags | IDBAccessObject::READ_LATEST,
1453                $page
1454            );
1455        }
1456        return iterator_to_array( $res );
1457    }
1458
1459    /**
1460     * Factory method for SlotRecords based on known slot rows.
1461     *
1462     * @param int $revId The revision to load slots for.
1463     * @param \stdClass[]|IResultWrapper $slotRows
1464     * @param int $queryFlags
1465     * @param PageIdentity $page
1466     * @param array|null $slotContents a map from blobAddress to slot
1467     *     content blob or Content object.
1468     *
1469     * @return SlotRecord[]
1470     */
1471    private function constructSlotRecords(
1472        $revId,
1473        $slotRows,
1474        $queryFlags,
1475        PageIdentity $page,
1476        $slotContents = null
1477    ) {
1478        $slots = [];
1479
1480        foreach ( $slotRows as $row ) {
1481            // Resolve role names and model names from in-memory cache, if they were not joined in.
1482            if ( !isset( $row->role_name ) ) {
1483                $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1484            }
1485
1486            if ( !isset( $row->model_name ) ) {
1487                if ( isset( $row->content_model ) ) {
1488                    $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1489                } else {
1490                    // We may get here if $row->model_name is set but null, perhaps because it
1491                    // came from rev_content_model, which is NULL for the default model.
1492                    $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1493                    $row->model_name = $slotRoleHandler->getDefaultModel( $page );
1494                }
1495            }
1496
1497            // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1498            if ( isset( $row->blob_data ) ) {
1499                $slotContents[$row->content_address] = $row->blob_data;
1500            }
1501
1502            $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1503                $blob = null;
1504                if ( isset( $slotContents[$slot->getAddress()] ) ) {
1505                    $blob = $slotContents[$slot->getAddress()];
1506                    if ( $blob instanceof Content ) {
1507                        return $blob;
1508                    }
1509                }
1510                return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1511            };
1512
1513            $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1514        }
1515
1516        if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1517            $this->logger->error(
1518                __METHOD__ . ': Main slot of revision not found in database. See T212428.',
1519                [
1520                    'revid' => $revId,
1521                    'queryFlags' => $queryFlags,
1522                    'exception' => new RuntimeException(),
1523                ]
1524            );
1525
1526            throw new RevisionAccessException(
1527                'Main slot of revision not found in database. See T212428.'
1528            );
1529        }
1530
1531        return $slots;
1532    }
1533
1534    /**
1535     * Factory method for RevisionSlots based on a revision ID.
1536     *
1537     * @note If other code has a need to construct RevisionSlots objects, this should be made
1538     * public, since RevisionSlots instances should not be constructed directly.
1539     *
1540     * @param int $revId
1541     * @param \stdClass[]|null $slotRows
1542     * @param int $queryFlags
1543     * @param PageIdentity $page
1544     *
1545     * @return RevisionSlots
1546     */
1547    private function newRevisionSlots(
1548        $revId,
1549        $slotRows,
1550        $queryFlags,
1551        PageIdentity $page
1552    ) {
1553        if ( $slotRows ) {
1554            $slots = new RevisionSlots(
1555                $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page )
1556            );
1557        } else {
1558            $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) {
1559                return $this->loadSlotRecords( $revId, $queryFlags, $page );
1560            } );
1561        }
1562
1563        return $slots;
1564    }
1565
1566    /**
1567     * Make a fake RevisionRecord object from an archive table row. This is queried
1568     * for permissions or even inserted (as in Special:Undelete)
1569     *
1570     * The user ID and user name may optionally be supplied using the aliases
1571     * ar_user and ar_user_text (the names of fields which existed before
1572     * MW 1.34).
1573     *
1574     * MCR migration note: this replaced Revision::newFromArchiveRow
1575     *
1576     * @param \stdClass $row
1577     * @param int $queryFlags
1578     * @param PageIdentity|null $page
1579     * @param array $overrides associative array with fields of $row to override. This may be
1580     *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
1581     *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
1582     *   override ar_parent_id.
1583     *
1584     * @return RevisionRecord
1585     */
1586    public function newRevisionFromArchiveRow(
1587        $row,
1588        $queryFlags = 0,
1589        PageIdentity $page = null,
1590        array $overrides = []
1591    ) {
1592        return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides );
1593    }
1594
1595    /**
1596     * @see RevisionFactory::newRevisionFromRow
1597     *
1598     * MCR migration note: this replaced Revision::newFromRow
1599     *
1600     * @param \stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1601     * @param int $queryFlags
1602     * @param PageIdentity|null $page Preloaded page object
1603     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1604     *   data is returned from getters, by querying the database as needed
1605     * @return RevisionRecord
1606     */
1607    public function newRevisionFromRow(
1608        $row,
1609        $queryFlags = 0,
1610        PageIdentity $page = null,
1611        $fromCache = false
1612    ) {
1613        return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache );
1614    }
1615
1616    /**
1617     * @see newRevisionFromArchiveRow()
1618     * @since 1.35
1619     *
1620     * @param stdClass $row
1621     * @param null|stdClass[]|RevisionSlots $slots
1622     *  - Database rows generated from a query based on getSlotsQueryInfo
1623     *    with the 'content' flag set. Or
1624     *  - RevisionSlots instance
1625     * @param int $queryFlags
1626     * @param PageIdentity|null $page
1627     * @param array $overrides associative array with fields of $row to override. This may be
1628     *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
1629     *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
1630     *   override ar_parent_id.
1631     *
1632     * @return RevisionRecord
1633     */
1634    public function newRevisionFromArchiveRowAndSlots(
1635        stdClass $row,
1636        $slots,
1637        int $queryFlags = 0,
1638        ?PageIdentity $page = null,
1639        array $overrides = []
1640    ) {
1641        if ( !$page && isset( $overrides['title'] ) ) {
1642            if ( !( $overrides['title'] instanceof PageIdentity ) ) {
1643                throw new InvalidArgumentException( 'title field override must contain a PageIdentity object.' );
1644            }
1645
1646            $page = $overrides['title'];
1647        }
1648
1649        if ( !isset( $page ) ) {
1650            if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1651                $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1652            } else {
1653                throw new InvalidArgumentException(
1654                    'A Title or ar_namespace and ar_title must be given'
1655                );
1656            }
1657        }
1658
1659        foreach ( $overrides as $key => $value ) {
1660            $field = "ar_$key";
1661            $row->$field = $value;
1662        }
1663
1664        try {
1665            $user = $this->actorStore->newActorFromRowFields(
1666                $row->ar_user ?? null,
1667                $row->ar_user_text ?? null,
1668                $row->ar_actor ?? null
1669            );
1670        } catch ( InvalidArgumentException $ex ) {
1671            $this->logger->warning( 'Could not load user for archive revision {rev_id}', [
1672                'ar_rev_id' => $row->ar_rev_id,
1673                'ar_actor' => $row->ar_actor ?? 'null',
1674                'ar_user_text' => $row->ar_user_text ?? 'null',
1675                'ar_user' => $row->ar_user ?? 'null',
1676                'exception' => $ex
1677            ] );
1678            $user = $this->actorStore->getUnknownActor();
1679        }
1680
1681        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1682        // Legacy because $row may have come from self::selectFields()
1683        $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1684
1685        if ( !( $slots instanceof RevisionSlots ) ) {
1686            $slots = $this->newRevisionSlots( (int)$row->ar_rev_id, $slots, $queryFlags, $page );
1687        }
1688        return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId );
1689    }
1690
1691    /**
1692     * @see newFromRevisionRow()
1693     *
1694     * @param stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1695     * @param null|stdClass[]|RevisionSlots $slots
1696     *  - Database rows generated from a query based on getSlotsQueryInfo
1697     *    with the 'content' flag set. Or
1698     *  - RevisionSlots instance
1699     * @param int $queryFlags
1700     * @param PageIdentity|null $page
1701     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1702     *   data is returned from getters, by querying the database as needed
1703     *
1704     * @return RevisionRecord
1705     * @throws RevisionAccessException
1706     * @see RevisionFactory::newRevisionFromRow
1707     */
1708    public function newRevisionFromRowAndSlots(
1709        stdClass $row,
1710        $slots,
1711        int $queryFlags = 0,
1712        ?PageIdentity $page = null,
1713        bool $fromCache = false
1714    ) {
1715        if ( !$page ) {
1716            if ( isset( $row->page_id )
1717                && isset( $row->page_namespace )
1718                && isset( $row->page_title )
1719            ) {
1720                $page = new PageIdentityValue(
1721                    (int)$row->page_id,
1722                    (int)$row->page_namespace,
1723                    $row->page_title,
1724                    $this->wikiId
1725                );
1726
1727                $page = $this->wrapPage( $page );
1728            } else {
1729                $pageId = (int)( $row->rev_page ?? 0 );
1730                $revId = (int)( $row->rev_id ?? 0 );
1731
1732                $page = $this->getPage( $pageId, $revId, $queryFlags );
1733            }
1734        } else {
1735            $page = $this->ensureRevisionRowMatchesPage( $row, $page );
1736        }
1737
1738        if ( !$page ) {
1739            // This should already have been caught about, but apparently
1740            // it not always is, see T286877.
1741            throw new RevisionAccessException(
1742                "Failed to determine page associated with revision {$row->rev_id}"
1743            );
1744        }
1745
1746        try {
1747            $user = $this->actorStore->newActorFromRowFields(
1748                $row->rev_user ?? null,
1749                $row->rev_user_text ?? null,
1750                $row->rev_actor ?? null
1751            );
1752        } catch ( InvalidArgumentException $ex ) {
1753            $this->logger->warning( 'Could not load user for revision {rev_id}', [
1754                'rev_id' => $row->rev_id,
1755                'rev_actor' => $row->rev_actor ?? 'null',
1756                'rev_user_text' => $row->rev_user_text ?? 'null',
1757                'rev_user' => $row->rev_user ?? 'null',
1758                'exception' => $ex
1759            ] );
1760            $user = $this->actorStore->getUnknownActor();
1761        }
1762
1763        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1764        // Legacy because $row may have come from self::selectFields()
1765        $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1766
1767        if ( !( $slots instanceof RevisionSlots ) ) {
1768            $slots = $this->newRevisionSlots( (int)$row->rev_id, $slots, $queryFlags, $page );
1769        }
1770
1771        // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data.
1772        if ( $fromCache ) {
1773            $rev = new RevisionStoreCacheRecord(
1774                function ( $revId ) use ( $queryFlags ) {
1775                    $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1776                    $row = $this->fetchRevisionRowFromConds(
1777                        $db,
1778                        [ 'rev_id' => intval( $revId ) ]
1779                    );
1780                    if ( !$row && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1781                        // If we found no slots, try looking on the primary database (T259738)
1782                        $this->logger->info(
1783                            'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.',
1784                            [
1785                                'revid' => $revId,
1786                                'exception' => new RuntimeException(),
1787                            ]
1788                        );
1789                        $dbw = $this->getDBConnectionRefForQueryFlags( IDBAccessObject::READ_LATEST );
1790                        $row = $this->fetchRevisionRowFromConds(
1791                            $dbw,
1792                            [ 'rev_id' => intval( $revId ) ]
1793                        );
1794                    }
1795                    if ( !$row ) {
1796                        return [ null, null ];
1797                    }
1798                    return [
1799                        $row->rev_deleted,
1800                        $this->actorStore->newActorFromRowFields(
1801                            $row->rev_user ?? null,
1802                            $row->rev_user_text ?? null,
1803                            $row->rev_actor ?? null
1804                        )
1805                    ];
1806                },
1807                $page, $user, $comment, $row, $slots, $this->wikiId
1808            );
1809        } else {
1810            $rev = new RevisionStoreRecord(
1811                $page, $user, $comment, $row, $slots, $this->wikiId );
1812        }
1813        return $rev;
1814    }
1815
1816    /**
1817     * Check that the given row matches the given Title object.
1818     * When a mismatch is detected, this tries to re-load the title from primary DB,
1819     * to avoid spurious errors during page moves.
1820     *
1821     * @param \stdClass $row
1822     * @param PageIdentity $page
1823     * @param array $context
1824     *
1825     * @return Pageidentity
1826     */
1827    private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) {
1828        $revId = (int)( $row->rev_id ?? 0 );
1829        $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id?
1830        $expectedPageId = $page->getId( $this->wikiId );
1831        // Avoid fatal error when the Title's ID changed, T246720
1832        if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) {
1833            // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here.
1834            $pageRec = $this->pageStore->getPageByName(
1835                $page->getNamespace(),
1836                $page->getDBkey(),
1837                IDBAccessObject::READ_LATEST
1838            );
1839            $masterPageId = $pageRec->getId( $this->wikiId );
1840            $masterLatest = $pageRec->getLatest( $this->wikiId );
1841            if ( $revPageId === $masterPageId ) {
1842                if ( $page instanceof Title ) {
1843                    // If we were using a Title object, keep using it, but update the page ID.
1844                    // This way, we don't unexpectedly mix Titles with immutable value objects.
1845                    $page->resetArticleID( $masterPageId );
1846
1847                } else {
1848                    $page = $pageRec;
1849                }
1850
1851                $this->logger->info(
1852                    "Encountered stale Title object",
1853                    [
1854                        'page_id_stale' => $expectedPageId,
1855                        'page_id_reloaded' => $masterPageId,
1856                        'page_latest' => $masterLatest,
1857                        'rev_id' => $revId,
1858                        'exception' => new RuntimeException(),
1859                    ] + $context
1860                );
1861            } else {
1862                $expectedTitle = (string)$page;
1863                if ( $page instanceof Title ) {
1864                    // If we started with a Title, keep using a Title.
1865                    $page = $this->titleFactory->newFromID( $revPageId );
1866                } else {
1867                    $page = $pageRec;
1868                }
1869
1870                // This could happen if a caller to e.g. getRevisionById supplied a Title that is
1871                // plain wrong. In this case, we should ideally throw an IllegalArgumentException.
1872                // However, it is more likely that we encountered a race condition during a page
1873                // move (T268910, T279832) or database corruption (T263340). That situation
1874                // should not be ignored, but we can allow the request to continue in a reasonable
1875                // manner without breaking things for the user.
1876                $this->logger->error(
1877                    "Encountered mismatching Title object (see T259022, T268910, T279832, T263340)",
1878                    [
1879                        'expected_page_id' => $masterPageId,
1880                        'expected_page_title' => $expectedTitle,
1881                        'rev_page' => $revPageId,
1882                        'rev_page_title' => (string)$page,
1883                        'page_latest' => $masterLatest,
1884                        'rev_id' => $revId,
1885                        'exception' => new RuntimeException(),
1886                    ] + $context
1887                );
1888            }
1889        }
1890
1891        // @phan-suppress-next-line PhanTypeMismatchReturnNullable getPageByName/newFromID should not return null
1892        return $page;
1893    }
1894
1895    /**
1896     * Construct a RevisionRecord instance for each row in $rows,
1897     * and return them as an associative array indexed by revision ID.
1898     * Use RevisionSelectQueryBuilder or getArchiveQueryInfo() to construct the
1899     * query that produces the rows.
1900     *
1901     * @param IResultWrapper|\stdClass[] $rows the rows to construct revision records from
1902     * @param array $options Supports the following options:
1903     *               'slots' - whether metadata about revision slots should be
1904     *               loaded immediately. Supports falsy or truthy value as well
1905     *               as an explicit list of slot role names. The main slot will
1906     *               always be loaded.
1907     *               'content' - whether the actual content of the slots should be
1908     *               preloaded.
1909     *               'archive' - whether the rows where generated using getArchiveQueryInfo(),
1910     *                           rather than getQueryInfo.
1911     * @param int $queryFlags
1912     * @param PageIdentity|null $page The page to which all the revision rows belong, if there
1913     *        is such a page and the caller has it handy, so we don't have to look it up again.
1914     *        If this parameter is given and any of the rows has a rev_page_id that is different
1915     *        from Article Id associated with the page, an InvalidArgumentException is thrown.
1916     *
1917     * @return StatusValue a status with a RevisionRecord[] of successfully fetched revisions
1918     *                     and an array of errors for the revisions failed to fetch.
1919     */
1920    public function newRevisionsFromBatch(
1921        $rows,
1922        array $options = [],
1923        $queryFlags = 0,
1924        PageIdentity $page = null
1925    ) {
1926        $result = new StatusValue();
1927        $archiveMode = $options['archive'] ?? false;
1928
1929        if ( $archiveMode ) {
1930            $revIdField = 'ar_rev_id';
1931        } else {
1932            $revIdField = 'rev_id';
1933        }
1934
1935        $rowsByRevId = [];
1936        $pageIdsToFetchTitles = [];
1937        $titlesByPageKey = [];
1938        foreach ( $rows as $row ) {
1939            if ( isset( $rowsByRevId[$row->$revIdField] ) ) {
1940                $result->warning(
1941                    'internalerror_info',
1942                    "Duplicate rows in newRevisionsFromBatch, $revIdField {$row->$revIdField}"
1943                );
1944            }
1945
1946            // Attach a page key to the row, so we can find and reuse Title objects easily.
1947            $row->_page_key =
1948                $archiveMode ? $row->ar_namespace . ':' . $row->ar_title : $row->rev_page;
1949
1950            if ( $page ) {
1951                if ( !$archiveMode && $row->rev_page != $this->getArticleId( $page ) ) {
1952                    throw new InvalidArgumentException(
1953                        "Revision {$row->$revIdField} doesn't belong to page "
1954                            . $this->getArticleId( $page )
1955                    );
1956                }
1957
1958                if ( $archiveMode
1959                    && ( $row->ar_namespace != $page->getNamespace()
1960                        || $row->ar_title !== $page->getDBkey() )
1961                ) {
1962                    throw new InvalidArgumentException(
1963                        "Revision {$row->$revIdField} doesn't belong to page "
1964                            . $page
1965                    );
1966                }
1967            } elseif ( !isset( $titlesByPageKey[ $row->_page_key ] ) ) {
1968                if ( isset( $row->page_namespace ) && isset( $row->page_title )
1969                    // This should always be true, but just in case we don't have a page_id
1970                    // set or it doesn't match rev_page, let's fetch the title again.
1971                    && isset( $row->page_id ) && isset( $row->rev_page )
1972                    && $row->rev_page === $row->page_id
1973                ) {
1974                    $titlesByPageKey[ $row->_page_key ] = Title::newFromRow( $row );
1975                } elseif ( $archiveMode ) {
1976                    // Can't look up deleted pages by ID, but we have namespace and title
1977                    $titlesByPageKey[ $row->_page_key ] =
1978                        Title::makeTitle( $row->ar_namespace, $row->ar_title );
1979                } else {
1980                    $pageIdsToFetchTitles[] = $row->rev_page;
1981                }
1982            }
1983            $rowsByRevId[$row->$revIdField] = $row;
1984        }
1985
1986        if ( !$rowsByRevId ) {
1987            $result->setResult( true, [] );
1988            return $result;
1989        }
1990
1991        // If the page is not supplied, batch-fetch Title objects.
1992        if ( $page ) {
1993            // same logic as for $row->_page_key above
1994            $pageKey = $archiveMode
1995                ? $page->getNamespace() . ':' . $page->getDBkey()
1996                : $this->getArticleId( $page );
1997
1998            $titlesByPageKey[$pageKey] = $page;
1999        } elseif ( $pageIdsToFetchTitles ) {
2000            // Note: when we fetch titles by ID, the page key is also the ID.
2001            // We should never get here if $archiveMode is true.
2002            Assert::invariant( !$archiveMode, 'Titles are not loaded by ID in archive mode.' );
2003
2004            $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles );
2005            $pageRecords = $this->pageStore
2006                ->newSelectQueryBuilder()
2007                ->wherePageIds( $pageIdsToFetchTitles )
2008                ->caller( __METHOD__ )
2009                ->fetchPageRecordArray();
2010            // Cannot array_merge because it re-indexes entries
2011            $titlesByPageKey = $pageRecords + $titlesByPageKey;
2012        }
2013
2014        // which method to use for creating RevisionRecords
2015        $newRevisionRecord = $archiveMode
2016            ? [ $this, 'newRevisionFromArchiveRowAndSlots' ]
2017            : [ $this, 'newRevisionFromRowAndSlots' ];
2018
2019        if ( !isset( $options['slots'] ) ) {
2020            $result->setResult(
2021                true,
2022                array_map(
2023                    static function ( $row )
2024                    use ( $queryFlags, $titlesByPageKey, $result, $newRevisionRecord, $revIdField ) {
2025                        try {
2026                            if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
2027                                $result->warning(
2028                                    'internalerror_info',
2029                                    "Couldn't find title for rev {$row->$revIdField} "
2030                                    . "(page key {$row->_page_key})"
2031                                );
2032                                return null;
2033                            }
2034                            return $newRevisionRecord( $row, null, $queryFlags,
2035                                $titlesByPageKey[ $row->_page_key ] );
2036                        } catch ( MWException $e ) {
2037                            $result->warning( 'internalerror_info', $e->getMessage() );
2038                            return null;
2039                        }
2040                    },
2041                    $rowsByRevId
2042                )
2043            );
2044            return $result;
2045        }
2046
2047        $slotRowOptions = [
2048            'slots' => $options['slots'] ?? true,
2049            'blobs' => $options['content'] ?? false,
2050        ];
2051
2052        if ( is_array( $slotRowOptions['slots'] )
2053            && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] )
2054        ) {
2055            // Make sure the main slot is always loaded, RevisionRecord requires this.
2056            $slotRowOptions['slots'][] = SlotRecord::MAIN;
2057        }
2058
2059        $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags );
2060
2061        $result->merge( $slotRowsStatus );
2062        $slotRowsByRevId = $slotRowsStatus->getValue();
2063
2064        $result->setResult(
2065            true,
2066            array_map(
2067                function ( $row )
2068                use ( $slotRowsByRevId, $queryFlags, $titlesByPageKey, $result,
2069                    $revIdField, $newRevisionRecord
2070                ) {
2071                    if ( !isset( $slotRowsByRevId[$row->$revIdField] ) ) {
2072                        $result->warning(
2073                            'internalerror_info',
2074                            "Couldn't find slots for rev {$row->$revIdField}"
2075                        );
2076                        return null;
2077                    }
2078                    if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
2079                        $result->warning(
2080                            'internalerror_info',
2081                            "Couldn't find title for rev {$row->$revIdField} "
2082                                . "(page key {$row->_page_key})"
2083                        );
2084                        return null;
2085                    }
2086                    try {
2087                        return $newRevisionRecord(
2088                            $row,
2089                            new RevisionSlots(
2090                                $this->constructSlotRecords(
2091                                    $row->$revIdField,
2092                                    $slotRowsByRevId[$row->$revIdField],
2093                                    $queryFlags,
2094                                    $titlesByPageKey[$row->_page_key]
2095                                )
2096                            ),
2097                            $queryFlags,
2098                            $titlesByPageKey[$row->_page_key]
2099                        );
2100                    } catch ( MWException $e ) {
2101                        $result->warning( 'internalerror_info', $e->getMessage() );
2102                        return null;
2103                    }
2104                },
2105                $rowsByRevId
2106            )
2107        );
2108        return $result;
2109    }
2110
2111    /**
2112     * Gets the slot rows associated with a batch of revisions.
2113     * The serialized content of each slot can be included by setting the 'blobs' option.
2114     * Callers are responsible for unserializing and interpreting the content blobs
2115     * based on the model_name and role_name fields.
2116     *
2117     * @param Traversable|array $rowsOrIds list of revision ids, or revision or archive rows
2118     *        from a db query.
2119     * @param array $options Supports the following options:
2120     *               'slots' - a list of slot role names to fetch. If omitted or true or null,
2121     *                         all slots are fetched
2122     *               'blobs' - whether the serialized content of each slot should be loaded.
2123     *                        If true, the serialized content will be present in the slot row
2124     *                        in the blob_data field.
2125     * @param int $queryFlags
2126     *
2127     * @return StatusValue a status containing, if isOK() returns true, a two-level nested
2128     *         associative array, mapping from revision ID to an associative array that maps from
2129     *         role name to a database row object. The database row object will contain the fields
2130     *         defined by getSlotQueryInfo() with the 'content' flag set, plus the blob_data field
2131     *         if the 'blobs' is set in $options. The model_name and role_name fields will also be
2132     *         set.
2133     */
2134    private function getSlotRowsForBatch(
2135        $rowsOrIds,
2136        array $options = [],
2137        $queryFlags = 0
2138    ) {
2139        $result = new StatusValue();
2140
2141        $revIds = [];
2142        foreach ( $rowsOrIds as $row ) {
2143            if ( is_object( $row ) ) {
2144                $revIds[] = isset( $row->ar_rev_id ) ? (int)$row->ar_rev_id : (int)$row->rev_id;
2145            } else {
2146                $revIds[] = (int)$row;
2147            }
2148        }
2149
2150        // Nothing to do.
2151        // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper.
2152        if ( !$revIds ) {
2153            $result->setResult( true, [] );
2154            return $result;
2155        }
2156
2157        // We need to set the `content` flag to join in content meta-data
2158        $slotQueryInfo = $this->getSlotsQueryInfo( [ 'content' ] );
2159        $revIdField = $slotQueryInfo['keys']['rev_id'];
2160        $slotQueryConds = [ $revIdField => $revIds ];
2161
2162        if ( isset( $options['slots'] ) && is_array( $options['slots'] ) ) {
2163            $slotIds = [];
2164            foreach ( $options['slots'] as $slot ) {
2165                try {
2166                    $slotIds[] = $this->slotRoleStore->getId( $slot );
2167                } catch ( NameTableAccessException $exception ) {
2168                    // Do not fail when slot has no id (unused slot)
2169                    // This also means for this slot are never data in the database
2170                }
2171            }
2172            if ( $slotIds === [] ) {
2173                // Degenerate case: return no slots for each revision.
2174                $result->setResult( true, array_fill_keys( $revIds, [] ) );
2175                return $result;
2176            }
2177
2178            $roleIdField = $slotQueryInfo['keys']['role_id'];
2179            $slotQueryConds[$roleIdField] = $slotIds;
2180        }
2181
2182        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
2183        $slotRows = $db->select(
2184            $slotQueryInfo['tables'],
2185            $slotQueryInfo['fields'],
2186            $slotQueryConds,
2187            __METHOD__,
2188            [],
2189            $slotQueryInfo['joins']
2190        );
2191
2192        $slotContents = null;
2193        if ( $options['blobs'] ?? false ) {
2194            $blobAddresses = [];
2195            foreach ( $slotRows as $slotRow ) {
2196                $blobAddresses[] = $slotRow->content_address;
2197            }
2198            $slotContentFetchStatus = $this->blobStore
2199                ->getBlobBatch( $blobAddresses, $queryFlags );
2200            foreach ( $slotContentFetchStatus->getErrors() as $error ) {
2201                $result->warning( $error['message'], ...$error['params'] );
2202            }
2203            $slotContents = $slotContentFetchStatus->getValue();
2204        }
2205
2206        $slotRowsByRevId = [];
2207        foreach ( $slotRows as $slotRow ) {
2208            if ( $slotContents === null ) {
2209                // nothing to do
2210            } elseif ( isset( $slotContents[$slotRow->content_address] ) ) {
2211                $slotRow->blob_data = $slotContents[$slotRow->content_address];
2212            } else {
2213                $result->warning(
2214                    'internalerror_info',
2215                    "Couldn't find blob data for rev {$slotRow->slot_revision_id}"
2216                );
2217                $slotRow->blob_data = null;
2218            }
2219
2220            // conditional needed for SCHEMA_COMPAT_READ_OLD
2221            if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) {
2222                $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id );
2223            }
2224
2225            // conditional needed for SCHEMA_COMPAT_READ_OLD
2226            if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) {
2227                $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model );
2228            }
2229
2230            $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow;
2231        }
2232
2233        $result->setResult( true, $slotRowsByRevId );
2234        return $result;
2235    }
2236
2237    /**
2238     * Gets raw (serialized) content blobs for the given set of revisions.
2239     * Callers are responsible for unserializing and interpreting the content blobs
2240     * based on the model_name field and the slot role.
2241     *
2242     * This method is intended for bulk operations in maintenance scripts.
2243     * It may be chosen over newRevisionsFromBatch by code that are only interested
2244     * in raw content, as opposed to meta data. Code that needs to access meta data of revisions,
2245     * slots, or content objects should use newRevisionsFromBatch() instead.
2246     *
2247     * @param Traversable|array $rowsOrIds list of revision ids, or revision rows from a db query.
2248     * @param array|null $slots the role names for which to get slots.
2249     * @param int $queryFlags
2250     *
2251     * @return StatusValue a status containing, if isOK() returns true, a two-level nested
2252     *         associative array, mapping from revision ID to an associative array that maps from
2253     *         role name to an anonymous object containing two fields:
2254     *         - model_name: the name of the content's model
2255     *         - blob_data: serialized content data
2256     */
2257    public function getContentBlobsForBatch(
2258        $rowsOrIds,
2259        $slots = null,
2260        $queryFlags = 0
2261    ) {
2262        $result = $this->getSlotRowsForBatch(
2263            $rowsOrIds,
2264            [ 'slots' => $slots, 'blobs' => true ],
2265            $queryFlags
2266        );
2267
2268        if ( $result->isOK() ) {
2269            // strip out all internal meta data that we don't want to expose
2270            foreach ( $result->value as $revId => $rowsByRole ) {
2271                foreach ( $rowsByRole as $role => $slotRow ) {
2272                    if ( is_array( $slots ) && !in_array( $role, $slots ) ) {
2273                        // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even
2274                        // if we didn't ask for it.
2275                        unset( $result->value[$revId][$role] );
2276                        continue;
2277                    }
2278
2279                    $result->value[$revId][$role] = (object)[
2280                        'blob_data' => $slotRow->blob_data,
2281                        'model_name' => $slotRow->model_name,
2282                    ];
2283                }
2284            }
2285        }
2286
2287        return $result;
2288    }
2289
2290    /**
2291     * Given a set of conditions, fetch a revision
2292     *
2293     * This method should be used if we are pretty sure the revision exists.
2294     * Unless $flags has READ_LATEST set, this method will first try to find the revision
2295     * on a replica before hitting the primary database.
2296     *
2297     * MCR migration note: this corresponded to Revision::newFromConds
2298     *
2299     * @param array $conditions
2300     * @param int $flags (optional)
2301     * @param PageIdentity|null $page (optional)
2302     * @param array $options (optional) additional query options
2303     *
2304     * @return RevisionRecord|null
2305     */
2306    private function newRevisionFromConds(
2307        array $conditions,
2308        int $flags = IDBAccessObject::READ_NORMAL,
2309        PageIdentity $page = null,
2310        array $options = []
2311    ) {
2312        $db = $this->getDBConnectionRefForQueryFlags( $flags );
2313        $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $page, $options );
2314
2315        // Make sure new pending/committed revision are visible later on
2316        // within web requests to certain avoid bugs like T93866 and T94407.
2317        if ( !$rev
2318            && !( $flags & IDBAccessObject::READ_LATEST )
2319            && $this->loadBalancer->hasStreamingReplicaServers()
2320            && $this->loadBalancer->hasOrMadeRecentPrimaryChanges()
2321        ) {
2322            $flags = IDBAccessObject::READ_LATEST;
2323            $dbw = $this->getDBConnection( DB_PRIMARY );
2324            $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $page, $options );
2325        }
2326
2327        return $rev;
2328    }
2329
2330    /**
2331     * Given a set of conditions, fetch a revision from
2332     * the given database connection.
2333     *
2334     * MCR migration note: this corresponded to Revision::loadFromConds
2335     *
2336     * @param IDatabase $db
2337     * @param array $conditions
2338     * @param int $flags (optional)
2339     * @param PageIdentity|null $page (optional) additional query options
2340     * @param array $options (optional) additional query options
2341     *
2342     * @return RevisionRecord|null
2343     */
2344    private function loadRevisionFromConds(
2345        IDatabase $db,
2346        array $conditions,
2347        int $flags = IDBAccessObject::READ_NORMAL,
2348        PageIdentity $page = null,
2349        array $options = []
2350    ) {
2351        $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags, $options );
2352        if ( $row ) {
2353            return $this->newRevisionFromRow( $row, $flags, $page );
2354        }
2355
2356        return null;
2357    }
2358
2359    /**
2360     * Throws an exception if the given database connection does not belong to the wiki this
2361     * RevisionStore is bound to.
2362     *
2363     * @param IReadableDatabase $db
2364     */
2365    private function checkDatabaseDomain( IReadableDatabase $db ) {
2366        $dbDomain = $db->getDomainID();
2367        $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId );
2368        if ( $dbDomain === $storeDomain ) {
2369            return;
2370        }
2371
2372        throw new RuntimeException( "DB connection domain '$dbDomain' does not match '$storeDomain'" );
2373    }
2374
2375    /**
2376     * Given a set of conditions, return a row with the
2377     * fields necessary to build RevisionRecord objects.
2378     *
2379     * MCR migration note: this corresponded to Revision::fetchFromConds
2380     *
2381     * @param IDatabase $db
2382     * @param array $conditions
2383     * @param int $flags (optional)
2384     * @param array $options (optional) additional query options
2385     *
2386     * @return \stdClass|false data row as a raw object
2387     */
2388    private function fetchRevisionRowFromConds(
2389        IDatabase $db,
2390        array $conditions,
2391        int $flags = IDBAccessObject::READ_NORMAL,
2392        array $options = []
2393    ) {
2394        $this->checkDatabaseDomain( $db );
2395
2396        $queryBuilder = $this->newSelectQueryBuilder( $db )
2397            ->joinComment()
2398            ->joinPage()
2399            ->joinUser()
2400            ->where( $conditions )
2401            ->options( $options );
2402        if ( ( $flags & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
2403            $queryBuilder->forUpdate();
2404        }
2405        return $queryBuilder->caller( __METHOD__ )->fetchRow();
2406    }
2407
2408    /**
2409     * Return the tables, fields, and join conditions to be selected to create
2410     * a new RevisionStoreRecord object.
2411     *
2412     * MCR migration note: this replaced Revision::getQueryInfo
2413     *
2414     * If the format of fields returned changes in any way then the cache key provided by
2415     * self::getRevisionRowCacheKey should be updated.
2416     *
2417     * @since 1.31
2418     * @deprecated since 1.41 use RevisionStore::newSelectQueryBuilder() instead.
2419     *
2420     * @param array $options Any combination of the following strings
2421     *  - 'page': Join with the page table, and select fields to identify the page
2422     *  - 'user': Join with the user table, and select the user name
2423     *
2424     * @return array[] With three keys:
2425     *  - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
2426     *  - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
2427     *  - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
2428     * @phan-return array{tables:string[],fields:string[],joins:array}
2429     */
2430    public function getQueryInfo( $options = [] ) {
2431        $ret = [
2432            'tables' => [],
2433            'fields' => [],
2434            'joins'  => [],
2435        ];
2436
2437        $ret['tables'] = array_merge( $ret['tables'], [
2438            'revision',
2439            'actor_rev_user' => 'actor',
2440        ] );
2441        $ret['fields'] = array_merge( $ret['fields'], [
2442            'rev_id',
2443            'rev_page',
2444            'rev_actor' => 'rev_actor',
2445            'rev_user' => 'actor_rev_user.actor_user',
2446            'rev_user_text' => 'actor_rev_user.actor_name',
2447            'rev_timestamp',
2448            'rev_minor_edit',
2449            'rev_deleted',
2450            'rev_len',
2451            'rev_parent_id',
2452            'rev_sha1',
2453        ] );
2454        $ret['joins']['actor_rev_user'] = [ 'JOIN', "actor_rev_user.actor_id = rev_actor" ];
2455
2456        $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
2457        $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
2458        $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
2459        $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
2460
2461        if ( in_array( 'page', $options, true ) ) {
2462            $ret['tables'][] = 'page';
2463            $ret['fields'] = array_merge( $ret['fields'], [
2464                'page_namespace',
2465                'page_title',
2466                'page_id',
2467                'page_latest',
2468                'page_is_redirect',
2469                'page_len',
2470            ] );
2471            $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
2472        }
2473
2474        if ( in_array( 'user', $options, true ) ) {
2475            $ret['tables'][] = 'user';
2476            $ret['fields'] = array_merge( $ret['fields'], [
2477                'user_name',
2478            ] );
2479            $ret['joins']['user'] = [
2480                'LEFT JOIN',
2481                [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ]
2482            ];
2483        }
2484
2485        if ( in_array( 'text', $options, true ) ) {
2486            throw new InvalidArgumentException(
2487                'The `text` option is no longer supported in MediaWiki 1.35 and later.'
2488            );
2489        }
2490
2491        return $ret;
2492    }
2493
2494    /**
2495     * @inheritDoc
2496     */
2497    public function newSelectQueryBuilder( IReadableDatabase $dbr ): RevisionSelectQueryBuilder {
2498        return new RevisionSelectQueryBuilder( $dbr );
2499    }
2500
2501    /**
2502     * @inheritDoc
2503     */
2504    public function newArchiveSelectQueryBuilder( IReadableDatabase $dbr ): ArchiveSelectQueryBuilder {
2505        return new ArchiveSelectQueryBuilder( $dbr );
2506    }
2507
2508    /**
2509     * Return the tables, fields, and join conditions to be selected to create
2510     * a new SlotRecord.
2511     *
2512     * @since 1.32
2513     *
2514     * @param array $options Any combination of the following strings
2515     *  - 'content': Join with the content table, and select content meta-data fields
2516     *  - 'model': Join with the content_models table, and select the model_name field.
2517     *             Only applicable if 'content' is also set.
2518     *  - 'role': Join with the slot_roles table, and select the role_name field
2519     *
2520     * @return array[] With three keys:
2521     *  - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
2522     *  - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
2523     *  - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
2524     *  - keys: (associative array) to look up fields to match against.
2525     *          In particular, the field that can be used to find slots by rev_id
2526     *          can be found in ['keys']['rev_id'].
2527     * @phan-return array{tables:string[],fields:string[],joins:array,keys:array}
2528     */
2529    public function getSlotsQueryInfo( $options = [] ) {
2530        $ret = [
2531            'tables' => [],
2532            'fields' => [],
2533            'joins'  => [],
2534            'keys'  => [],
2535        ];
2536
2537        $ret['keys']['rev_id'] = 'slot_revision_id';
2538        $ret['keys']['role_id'] = 'slot_role_id';
2539
2540        $ret['tables'][] = 'slots';
2541        $ret['fields'] = array_merge( $ret['fields'], [
2542            'slot_revision_id',
2543            'slot_content_id',
2544            'slot_origin',
2545            'slot_role_id',
2546        ] );
2547
2548        if ( in_array( 'role', $options, true ) ) {
2549            // Use left join to attach role name, so we still find the revision row even
2550            // if the role name is missing. This triggers a more obvious failure mode.
2551            $ret['tables'][] = 'slot_roles';
2552            $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
2553            $ret['fields'][] = 'role_name';
2554        }
2555
2556        if ( in_array( 'content', $options, true ) ) {
2557            $ret['keys']['model_id'] = 'content_model';
2558
2559            $ret['tables'][] = 'content';
2560            $ret['fields'] = array_merge( $ret['fields'], [
2561                'content_size',
2562                'content_sha1',
2563                'content_address',
2564                'content_model',
2565            ] );
2566            $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
2567
2568            if ( in_array( 'model', $options, true ) ) {
2569                // Use left join to attach model name, so we still find the revision row even
2570                // if the model name is missing. This triggers a more obvious failure mode.
2571                $ret['tables'][] = 'content_models';
2572                $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
2573                $ret['fields'][] = 'model_name';
2574            }
2575
2576        }
2577
2578        return $ret;
2579    }
2580
2581    /**
2582     * Determine whether the parameter is a row containing all the fields
2583     * that RevisionStore needs to create a RevisionRecord from the row.
2584     *
2585     * @param mixed $row
2586     * @param string $table 'archive' or empty
2587     * @return bool
2588     */
2589    public function isRevisionRow( $row, string $table = '' ) {
2590        if ( !( $row instanceof stdClass ) ) {
2591            return false;
2592        }
2593        $queryInfo = $table === 'archive' ? $this->getArchiveQueryInfo() : $this->getQueryInfo();
2594        foreach ( $queryInfo['fields'] as $alias => $field ) {
2595            $name = is_numeric( $alias ) ? $field : $alias;
2596            if ( !property_exists( $row, $name ) ) {
2597                return false;
2598            }
2599        }
2600        return true;
2601    }
2602
2603    /**
2604     * Return the tables, fields, and join conditions to be selected to create
2605     * a new RevisionArchiveRecord object.
2606     *
2607     * Since 1.34, ar_user and ar_user_text have not been present in the
2608     * database, but they continue to be available in query results as
2609     * aliases.
2610     *
2611     * MCR migration note: this replaced Revision::getArchiveQueryInfo
2612     *
2613     * @since 1.31
2614     * @deprecated since 1.41 use RevisionStore::newArchiveSelectQueryBuilder() instead.
2615     *
2616     * @return array[] With three keys:
2617     *   - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
2618     *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
2619     *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
2620     * @phan-return array{tables:string[],fields:string[],joins:array}
2621     */
2622    public function getArchiveQueryInfo() {
2623        $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
2624        $ret = [
2625            'tables' => [
2626                'archive',
2627                'archive_actor' => 'actor'
2628            ] + $commentQuery['tables'],
2629            'fields' => [
2630                'ar_id',
2631                'ar_page_id',
2632                'ar_namespace',
2633                'ar_title',
2634                'ar_rev_id',
2635                'ar_timestamp',
2636                'ar_minor_edit',
2637                'ar_deleted',
2638                'ar_len',
2639                'ar_parent_id',
2640                'ar_sha1',
2641                'ar_actor',
2642                'ar_user' => 'archive_actor.actor_user',
2643                'ar_user_text' => 'archive_actor.actor_name',
2644            ] + $commentQuery['fields'],
2645            'joins' => [
2646                'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ]
2647            ] + $commentQuery['joins'],
2648        ];
2649
2650        return $ret;
2651    }
2652
2653    /**
2654     * Do a batched query for the sizes of a set of revisions.
2655     *
2656     * MCR migration note: this replaced Revision::getParentLengths
2657     *
2658     * @param int[] $revIds
2659     * @return int[] associative array mapping revision IDs from $revIds to the nominal size
2660     *         of the corresponding revision.
2661     */
2662    public function getRevisionSizes( array $revIds ) {
2663        $dbr = $this->getDBConnection( DB_REPLICA );
2664        $revLens = [];
2665        if ( !$revIds ) {
2666            return $revLens; // empty
2667        }
2668
2669        $res = $dbr->newSelectQueryBuilder()
2670            ->select( [ 'rev_id', 'rev_len' ] )
2671            ->from( 'revision' )
2672            ->where( [ 'rev_id' => $revIds ] )
2673            ->caller( __METHOD__ )->fetchResultSet();
2674
2675        foreach ( $res as $row ) {
2676            $revLens[$row->rev_id] = intval( $row->rev_len );
2677        }
2678
2679        return $revLens;
2680    }
2681
2682    /**
2683     * Implementation of getPreviousRevision and getNextRevision.
2684     *
2685     * @param RevisionRecord $rev
2686     * @param int $flags
2687     * @param string $dir 'next' or 'prev'
2688     * @return RevisionRecord|null
2689     */
2690    private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
2691        $op = $dir === 'next' ? '>' : '<';
2692        $sort = $dir === 'next' ? 'ASC' : 'DESC';
2693
2694        $revisionIdValue = $rev->getId( $this->wikiId );
2695
2696        if ( !$revisionIdValue || !$rev->getPageId( $this->wikiId ) ) {
2697            // revision is unsaved or otherwise incomplete
2698            return null;
2699        }
2700
2701        if ( $rev instanceof RevisionArchiveRecord ) {
2702            // revision is deleted, so it's not part of the page history
2703            return null;
2704        }
2705
2706        if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
2707            $dbType = DB_PRIMARY;
2708        } else {
2709            $dbType = DB_REPLICA;
2710        }
2711        $db = $this->getDBConnection( $dbType );
2712
2713        $ts = $rev->getTimestamp() ?? $this->getTimestampFromId( $revisionIdValue, $flags );
2714        if ( $ts === false ) {
2715            // XXX Should this be moved into getTimestampFromId?
2716            $ts = $db->newSelectQueryBuilder()
2717                ->select( 'ar_timestamp' )
2718                ->from( 'archive' )
2719                ->where( [ 'ar_rev_id' => $revisionIdValue ] )
2720                ->caller( __METHOD__ )->fetchField();
2721            if ( $ts === false ) {
2722                // XXX Is this reachable? How can we have a page id but no timestamp?
2723                return null;
2724            }
2725        }
2726
2727        $revId = $db->selectField( 'revision', 'rev_id',
2728            [
2729                'rev_page' => $rev->getPageId( $this->wikiId ),
2730                $db->buildComparison( $op, [
2731                    'rev_timestamp' => $db->timestamp( $ts ),
2732                    'rev_id' => $revisionIdValue,
2733                ] ),
2734            ],
2735            __METHOD__,
2736            [
2737                'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ],
2738                'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
2739            ]
2740        );
2741
2742        if ( $revId === false ) {
2743            return null;
2744        }
2745
2746        return $this->getRevisionById( intval( $revId ), $flags );
2747    }
2748
2749    /**
2750     * Get the revision before $rev in the page's history, if any.
2751     * Will return null for the first revision but also for deleted or unsaved revisions.
2752     *
2753     * MCR migration note: this replaced Revision::getPrevious
2754     *
2755     * @see PageArchive::getPreviousRevisionRecord
2756     *
2757     * @param RevisionRecord $rev
2758     * @param int $flags (optional) $flags include:
2759     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
2760     *
2761     * @return RevisionRecord|null
2762     */
2763    public function getPreviousRevision( RevisionRecord $rev, $flags = IDBAccessObject::READ_NORMAL ) {
2764        return $this->getRelativeRevision( $rev, $flags, 'prev' );
2765    }
2766
2767    /**
2768     * Get the revision after $rev in the page's history, if any.
2769     * Will return null for the latest revision but also for deleted or unsaved revisions.
2770     *
2771     * MCR migration note: this replaced Revision::getNext
2772     *
2773     * @param RevisionRecord $rev
2774     * @param int $flags (optional) $flags include:
2775     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
2776     * @return RevisionRecord|null
2777     */
2778    public function getNextRevision( RevisionRecord $rev, $flags = IDBAccessObject::READ_NORMAL ) {
2779        return $this->getRelativeRevision( $rev, $flags, 'next' );
2780    }
2781
2782    /**
2783     * Get previous revision Id for this page_id
2784     * This is used to populate rev_parent_id on save
2785     *
2786     * MCR migration note: this corresponded to Revision::getPreviousRevisionId
2787     *
2788     * @param IDatabase $db
2789     * @param RevisionRecord $rev
2790     *
2791     * @return int
2792     */
2793    private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
2794        $this->checkDatabaseDomain( $db );
2795
2796        if ( $rev->getPageId( $this->wikiId ) === null ) {
2797            return 0;
2798        }
2799        # Use page_latest if ID is not given
2800        if ( !$rev->getId( $this->wikiId ) ) {
2801            $prevId = $db->newSelectQueryBuilder()
2802                ->select( 'page_latest' )
2803                ->from( 'page' )
2804                ->where( [ 'page_id' => $rev->getPageId( $this->wikiId ) ] )
2805                ->caller( __METHOD__ )->fetchField();
2806        } else {
2807            $prevId = $db->newSelectQueryBuilder()
2808                ->select( 'rev_id' )
2809                ->from( 'revision' )
2810                ->where( [ 'rev_page' => $rev->getPageId( $this->wikiId ) ] )
2811                ->andWhere( 'rev_id < ' . $rev->getId( $this->wikiId ) )
2812                ->orderBy( 'rev_id DESC' )
2813                ->caller( __METHOD__ )->fetchField();
2814        }
2815        return intval( $prevId );
2816    }
2817
2818    /**
2819     * Get rev_timestamp from rev_id, without loading the rest of the row.
2820     *
2821     * Historically, there was an extra Title parameter that was passed before $id. This is no
2822     * longer needed and is deprecated in 1.34.
2823     *
2824     * MCR migration note: this replaced Revision::getTimestampFromId
2825     *
2826     * @param int $id
2827     * @param int $flags
2828     * @return string|false False if not found
2829     */
2830    public function getTimestampFromId( $id, $flags = 0 ) {
2831        if ( $id instanceof Title ) {
2832            // Old deprecated calling convention supported for backwards compatibility
2833            $id = $flags;
2834            $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
2835        }
2836
2837        // T270149: Bail out if we know the query will definitely return false. Some callers are
2838        // passing RevisionRecord::getId() call directly as $id which can possibly return null.
2839        // Null $id or $id <= 0 will lead to useless query with WHERE clause of 'rev_id IS NULL'
2840        // or 'rev_id = 0', but 'rev_id' is always greater than zero and cannot be null.
2841        // @todo typehint $id and remove the null check
2842        if ( $id === null || $id <= 0 ) {
2843            return false;
2844        }
2845
2846        $db = $this->getDBConnectionRefForQueryFlags( $flags );
2847
2848        $timestamp =
2849            $db->newSelectQueryBuilder()
2850                ->select( 'rev_timestamp' )
2851                ->from( 'revision' )
2852                ->where( [ 'rev_id' => $id ] )
2853                ->caller( __METHOD__ )->fetchField();
2854
2855        return ( $timestamp !== false ) ? MWTimestamp::convert( TS_MW, $timestamp ) : false;
2856    }
2857
2858    /**
2859     * Get count of revisions per page...not very efficient
2860     *
2861     * MCR migration note: this replaced Revision::countByPageId
2862     *
2863     * @param IReadableDatabase $db
2864     * @param int $id Page id
2865     * @return int
2866     */
2867    public function countRevisionsByPageId( IReadableDatabase $db, $id ) {
2868        $this->checkDatabaseDomain( $db );
2869
2870        $row = $db->newSelectQueryBuilder()
2871            ->select( [ 'revCount' => 'COUNT(*)' ] )
2872            ->from( 'revision' )
2873            ->where( [ 'rev_page' => $id ] )
2874            ->caller( __METHOD__ )->fetchRow();
2875        if ( $row ) {
2876            return intval( $row->revCount );
2877        }
2878        return 0;
2879    }
2880
2881    /**
2882     * Get count of revisions per page...not very efficient
2883     *
2884     * MCR migration note: this replaced Revision::countByTitle
2885     *
2886     * @param IDatabase $db
2887     * @param PageIdentity $page
2888     * @return int
2889     */
2890    public function countRevisionsByTitle( IDatabase $db, PageIdentity $page ) {
2891        $id = $this->getArticleId( $page );
2892        if ( $id ) {
2893            return $this->countRevisionsByPageId( $db, $id );
2894        }
2895        return 0;
2896    }
2897
2898    /**
2899     * Check if no edits were made by other users since
2900     * the time a user started editing the page. Limit to
2901     * 50 revisions for the sake of performance.
2902     *
2903     * MCR migration note: this replaced Revision::userWasLastToEdit
2904     *
2905     * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression
2906     *       logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit
2907     *       had been deprecated since 1.24 (the Revision class was removed entirely in 1.37).
2908     *
2909     * @param IDatabase $db The Database to perform the check on.
2910     * @param int $pageId The ID of the page in question
2911     * @param int $userId The ID of the user in question
2912     * @param string $since Look at edits since this time
2913     *
2914     * @return bool True if the given user was the only one to edit since the given timestamp
2915     */
2916    public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
2917        $this->checkDatabaseDomain( $db );
2918
2919        if ( !$userId ) {
2920            return false;
2921        }
2922
2923        $queryBuilder = $this->newSelectQueryBuilder( $db )
2924            ->where( [
2925                'rev_page' => $pageId,
2926                $db->expr( 'rev_timestamp', '>', $db->timestamp( $since ) )
2927            ] )
2928            ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_ASC )
2929            ->limit( 50 );
2930        $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
2931        foreach ( $res as $row ) {
2932            if ( $row->rev_user != $userId ) {
2933                return false;
2934            }
2935        }
2936        return true;
2937    }
2938
2939    /**
2940     * Load a revision based on a known page ID and current revision ID from the DB
2941     *
2942     * This method allows for the use of caching, though accessing anything that normally
2943     * requires permission checks (aside from the text) will trigger a small DB lookup.
2944     *
2945     * MCR migration note: this replaced Revision::newKnownCurrent
2946     *
2947     * @param PageIdentity $page the associated page
2948     * @param int $revId current revision of this page. Defaults to $title->getLatestRevID().
2949     *
2950     * @return RevisionRecord|false Returns false if missing
2951     */
2952    public function getKnownCurrentRevision( PageIdentity $page, $revId = 0 ) {
2953        $db = $this->getDBConnection( DB_REPLICA );
2954        $revIdPassed = $revId;
2955        $pageId = $this->getArticleId( $page );
2956        if ( !$pageId ) {
2957            return false;
2958        }
2959
2960        if ( !$revId ) {
2961            if ( $page instanceof Title ) {
2962                $revId = $page->getLatestRevID();
2963            } else {
2964                $pageRecord = $this->pageStore->getPageByReference( $page );
2965                if ( $pageRecord ) {
2966                    $revId = $pageRecord->getLatest( $this->getWikiId() );
2967                }
2968            }
2969        }
2970
2971        if ( !$revId ) {
2972            $this->logger->warning(
2973                'No latest revision known for page {page} even though it exists with page ID {page_id}', [
2974                    'page' => $page->__toString(),
2975                    'page_id' => $pageId,
2976                    'wiki_id' => $this->getWikiId() ?: 'local',
2977                ] );
2978            return false;
2979        }
2980
2981        // Load the row from cache if possible.  If not possible, populate the cache.
2982        // As a minor optimization, remember if this was a cache hit or miss.
2983        // We can sometimes avoid a database query later if this is a cache miss.
2984        $fromCache = true;
2985        $row = $this->cache->getWithSetCallback(
2986            // Page/rev IDs passed in from DB to reflect history merges
2987            $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
2988            WANObjectCache::TTL_WEEK,
2989            function ( $curValue, &$ttl, array &$setOpts ) use (
2990                $db, $revId, &$fromCache
2991            ) {
2992                $setOpts += Database::getCacheSetOptions( $db );
2993                $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
2994                if ( $row ) {
2995                    $fromCache = false;
2996                }
2997                return $row; // don't cache negatives
2998            }
2999        );
3000
3001        // Reflect revision deletion and user renames.
3002        if ( $row ) {
3003            $title = $this->ensureRevisionRowMatchesPage( $row, $page, [
3004                'from_cache_flag' => $fromCache,
3005                'page_id_initial' => $pageId,
3006                'rev_id_used' => $revId,
3007                'rev_id_requested' => $revIdPassed,
3008            ] );
3009
3010            return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
3011        } else {
3012            return false;
3013        }
3014    }
3015
3016    /**
3017     * Get the first revision of a given page.
3018     *
3019     * @since 1.35
3020     * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36
3021     * @param int $flags
3022     * @return RevisionRecord|null
3023     */
3024    public function getFirstRevision(
3025        $page,
3026        int $flags = IDBAccessObject::READ_NORMAL
3027    ): ?RevisionRecord {
3028        if ( $page instanceof LinkTarget ) {
3029            // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
3030            $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
3031        }
3032        return $this->newRevisionFromConds(
3033            [
3034                'page_namespace' => $page->getNamespace(),
3035                'page_title' => $page->getDBkey()
3036            ],
3037            $flags,
3038            $page,
3039            [
3040                'ORDER BY' => [ 'rev_timestamp ASC', 'rev_id ASC' ],
3041                'IGNORE INDEX' => [ 'revision' => 'rev_timestamp' ], // See T159319
3042            ]
3043        );
3044    }
3045
3046    /**
3047     * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] )
3048     * Caching rows without 'page' or 'user' could lead to issues.
3049     * If the format of the rows returned by the query provided by getQueryInfo changes the
3050     * cache key should be updated to avoid conflicts.
3051     *
3052     * @param IDatabase $db
3053     * @param int $pageId
3054     * @param int $revId
3055     * @return string
3056     */
3057    private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
3058        return $this->cache->makeGlobalKey(
3059            self::ROW_CACHE_KEY,
3060            $db->getDomainID(),
3061            $pageId,
3062            $revId
3063        );
3064    }
3065
3066    /**
3067     * Asserts that if revision is provided, it's saved and belongs to the page with provided pageId.
3068     * @param string $paramName
3069     * @param int $pageId
3070     * @param RevisionRecord|null $rev
3071     * @throws InvalidArgumentException
3072     */
3073    private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) {
3074        if ( $rev ) {
3075            if ( $rev->getId( $this->wikiId ) === null ) {
3076                throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" );
3077            }
3078            if ( $rev->getPageId( $this->wikiId ) !== $pageId ) {
3079                throw new InvalidArgumentException(
3080                    "Revision {$rev->getId( $this->wikiId )} doesn't belong to page {$pageId}"
3081                );
3082            }
3083        }
3084    }
3085
3086    /**
3087     * Converts revision limits to query conditions.
3088     *
3089     * @param ISQLPlatform $dbr
3090     * @param RevisionRecord|null $old Old revision.
3091     *  If null is provided, count starting from the first revision (inclusive).
3092     * @param RevisionRecord|null $new New revision.
3093     *  If null is provided, count until the last revision (inclusive).
3094     * @param string|array $options Single option, or an array of options:
3095     *     RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded.
3096     *     RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded.
3097     *     RevisionStore::INCLUDE_BOTH Include both $old and $new in the range.
3098     * @return array
3099     */
3100    private function getRevisionLimitConditions(
3101        ISQLPlatform $dbr,
3102        RevisionRecord $old = null,
3103        RevisionRecord $new = null,
3104        $options = []
3105    ) {
3106        $options = (array)$options;
3107        if ( in_array( self::INCLUDE_OLD, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) {
3108            $oldCmp = '>=';
3109        } else {
3110            $oldCmp = '>';
3111        }
3112        if ( in_array( self::INCLUDE_NEW, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) {
3113            $newCmp = '<=';
3114        } else {
3115            $newCmp = '<';
3116        }
3117
3118        $conds = [];
3119        if ( $old ) {
3120            $conds[] = $dbr->buildComparison( $oldCmp, [
3121                'rev_timestamp' => $dbr->timestamp( $old->getTimestamp() ),
3122                'rev_id' => $old->getId( $this->wikiId ),
3123            ] );
3124        }
3125        if ( $new ) {
3126            $conds[] = $dbr->buildComparison( $newCmp, [
3127                'rev_timestamp' => $dbr->timestamp( $new->getTimestamp() ),
3128                'rev_id' => $new->getId( $this->wikiId ),
3129            ] );
3130        }
3131        return $conds;
3132    }
3133
3134    /**
3135     * Get IDs of revisions between the given revisions.
3136     *
3137     * @since 1.36
3138     *
3139     * @param int $pageId The id of the page
3140     * @param RevisionRecord|null $old Old revision.
3141     *  If null is provided, count starting from the first revision (inclusive).
3142     * @param RevisionRecord|null $new New revision.
3143     *  If null is provided, count until the last revision (inclusive).
3144     * @param int|null $max Limit of Revisions to count, will be incremented by
3145     *  one to detect truncations.
3146     * @param string|array $options Single option, or an array of options:
3147     *     RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded.
3148     *     RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded.
3149     *     RevisionStore::INCLUDE_BOTH Include both $old and $new in the range.
3150     * @param string|null $order The direction in which the revisions should be sorted.
3151     *  Possible values:
3152     *   - RevisionStore::ORDER_OLDEST_TO_NEWEST
3153     *   - RevisionStore::ORDER_NEWEST_TO_OLDEST
3154     *   - null for no specific ordering (default value)
3155     * @param int $flags
3156     * @throws InvalidArgumentException in case either revision is unsaved or
3157     *  the revisions do not belong to the same page or unknown option is passed.
3158     * @return int[]
3159     */
3160    public function getRevisionIdsBetween(
3161        int $pageId,
3162        RevisionRecord $old = null,
3163        RevisionRecord $new = null,
3164        ?int $max = null,
3165        $options = [],
3166        ?string $order = null,
3167        int $flags = IDBAccessObject::READ_NORMAL
3168    ): array {
3169        $this->assertRevisionParameter( 'old', $pageId, $old );
3170        $this->assertRevisionParameter( 'new', $pageId, $new );
3171
3172        $options = (array)$options;
3173        $includeOld = in_array( self::INCLUDE_OLD, $options ) ||
3174            in_array( self::INCLUDE_BOTH, $options );
3175        $includeNew = in_array( self::INCLUDE_NEW, $options ) ||
3176            in_array( self::INCLUDE_BOTH, $options );
3177
3178        // No DB query needed if old and new are the same revision.
3179        // Can't check for consecutive revisions with 'getParentId' for a similar
3180        // optimization as edge cases exist when there are revisions between
3181        // a revision and it's parent. See T185167 for more details.
3182        if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3183            return $includeOld || $includeNew ? [ $new->getId( $this->wikiId ) ] : [];
3184        }
3185
3186        $db = $this->getDBConnectionRefForQueryFlags( $flags );
3187        $queryBuilder = $db->newSelectQueryBuilder()
3188            ->select( 'rev_id' )
3189            ->from( 'revision' )
3190            ->where( [
3191                'rev_page' => $pageId,
3192                $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
3193            ] )
3194            ->andWhere( $this->getRevisionLimitConditions( $db, $old, $new, $options ) );
3195
3196        if ( $order !== null ) {
3197            $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], $order );
3198        }
3199        if ( $max !== null ) {
3200            // extra to detect truncation
3201            $queryBuilder->limit( $max + 1 );
3202        }
3203
3204        $values = $queryBuilder->caller( __METHOD__ )->fetchFieldValues();
3205        return array_map( 'intval', $values );
3206    }
3207
3208    /**
3209     * Get the authors between the given revisions or revisions.
3210     * Used for diffs and other things that really need it.
3211     *
3212     * @since 1.35
3213     *
3214     * @param int $pageId The id of the page
3215     * @param RevisionRecord|null $old Old revision.
3216     *  If null is provided, count starting from the first revision (inclusive).
3217     * @param RevisionRecord|null $new New revision.
3218     *  If null is provided, count until the last revision (inclusive).
3219     * @param Authority|null $performer the user whose access rights to apply
3220     * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations.
3221     * @param string|array $options Single option, or an array of options:
3222     *     RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded.
3223     *     RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded.
3224     *     RevisionStore::INCLUDE_BOTH Include both $old and $new in the range.
3225     * @throws InvalidArgumentException in case either revision is unsaved or
3226     *  the revisions do not belong to the same page or unknown option is passed.
3227     * @return UserIdentity[] Names of revision authors in the range
3228     */
3229    public function getAuthorsBetween(
3230        $pageId,
3231        RevisionRecord $old = null,
3232        RevisionRecord $new = null,
3233        Authority $performer = null,
3234        $max = null,
3235        $options = []
3236    ) {
3237        $this->assertRevisionParameter( 'old', $pageId, $old );
3238        $this->assertRevisionParameter( 'new', $pageId, $new );
3239        $options = (array)$options;
3240
3241        // No DB query needed if old and new are the same revision.
3242        // Can't check for consecutive revisions with 'getParentId' for a similar
3243        // optimization as edge cases exist when there are revisions between
3244        //a revision and it's parent. See T185167 for more details.
3245        if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3246            if ( !$options ) {
3247                return [];
3248            } elseif ( $performer ) {
3249                return [ $new->getUser( RevisionRecord::FOR_THIS_USER, $performer ) ];
3250            } else {
3251                return [ $new->getUser() ];
3252            }
3253        }
3254
3255        $dbr = $this->getDBConnection( DB_REPLICA );
3256        $conds = array_merge(
3257            [
3258                'rev_page' => $pageId,
3259                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"
3260            ],
3261            $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3262        );
3263
3264        $queryOpts = [ 'DISTINCT' ];
3265        if ( $max !== null ) {
3266            $queryOpts['LIMIT'] = $max + 1;
3267        }
3268
3269        return array_map( function ( $row ) {
3270            return $this->actorStore->newActorFromRowFields(
3271                $row->rev_user,
3272                $row->rev_user_text,
3273                $row->rev_actor
3274            );
3275        }, iterator_to_array( $dbr->select(
3276            [ 'revision', 'revision_actor' => 'actor' ],
3277            [
3278                'rev_actor',
3279                'rev_user' => 'revision_actor.actor_user',
3280                'rev_user_text' => 'revision_actor.actor_name',
3281            ],
3282            $conds,
3283            __METHOD__,
3284            $queryOpts,
3285            [ 'revision_actor' => [ 'JOIN', 'revision_actor.actor_id = rev_actor' ] ]
3286        ) ) );
3287    }
3288
3289    /**
3290     * Get the number of authors between the given revisions.
3291     * Used for diffs and other things that really need it.
3292     *
3293     * @since 1.35
3294     *
3295     * @param int $pageId The id of the page
3296     * @param RevisionRecord|null $old Old revision .
3297     *  If null is provided, count starting from the first revision (inclusive).
3298     * @param RevisionRecord|null $new New revision.
3299     *  If null is provided, count until the last revision (inclusive).
3300     * @param Authority|null $performer the user whose access rights to apply
3301     * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations.
3302     * @param string|array $options Single option, or an array of options:
3303     *     RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded.
3304     *     RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded.
3305     *     RevisionStore::INCLUDE_BOTH Include both $old and $new in the range.
3306     * @throws InvalidArgumentException in case either revision is unsaved or
3307     *  the revisions do not belong to the same page or unknown option is passed.
3308     * @return int Number of revisions authors in the range.
3309     */
3310    public function countAuthorsBetween(
3311        $pageId,
3312        RevisionRecord $old = null,
3313        RevisionRecord $new = null,
3314        Authority $performer = null,
3315        $max = null,
3316        $options = []
3317    ) {
3318        // TODO: Implement with a separate query to avoid cost of selecting unneeded fields
3319        // and creation of UserIdentity stuff.
3320        return count( $this->getAuthorsBetween( $pageId, $old, $new, $performer, $max, $options ) );
3321    }
3322
3323    /**
3324     * Get the number of revisions between the given revisions.
3325     * Used for diffs and other things that really need it.
3326     *
3327     * @since 1.35
3328     *
3329     * @param int $pageId The id of the page
3330     * @param RevisionRecord|null $old Old revision.
3331     *  If null is provided, count starting from the first revision (inclusive).
3332     * @param RevisionRecord|null $new New revision.
3333     *  If null is provided, count until the last revision (inclusive).
3334     * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations.
3335     * @param string|array $options Single option, or an array of options:
3336     *     RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded.
3337     *     RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded.
3338     *     RevisionStore::INCLUDE_BOTH Include both $old and $new in the range.
3339     * @throws InvalidArgumentException in case either revision is unsaved or
3340     *  the revisions do not belong to the same page.
3341     * @return int Number of revisions between these revisions.
3342     */
3343    public function countRevisionsBetween(
3344        $pageId,
3345        RevisionRecord $old = null,
3346        RevisionRecord $new = null,
3347        $max = null,
3348        $options = []
3349    ) {
3350        $this->assertRevisionParameter( 'old', $pageId, $old );
3351        $this->assertRevisionParameter( 'new', $pageId, $new );
3352
3353        // No DB query needed if old and new are the same revision.
3354        // Can't check for consecutive revisions with 'getParentId' for a similar
3355        // optimization as edge cases exist when there are revisions between
3356        //a revision and it's parent. See T185167 for more details.
3357        if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3358            return 0;
3359        }
3360
3361        $dbr = $this->getDBConnection( DB_REPLICA );
3362        $conds = array_merge(
3363            [
3364                'rev_page' => $pageId,
3365                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
3366            ],
3367            $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3368        );
3369        if ( $max !== null ) {
3370            return $dbr->selectRowCount( 'revision', '1',
3371                $conds,
3372                __METHOD__,
3373                [ 'LIMIT' => $max + 1 ] // extra to detect truncation
3374            );
3375        } else {
3376            return (int)$dbr->newSelectQueryBuilder()
3377                ->select( 'count(*)' )
3378                ->from( 'revision' )
3379                ->where( $conds )
3380                ->caller( __METHOD__ )->fetchField();
3381        }
3382    }
3383
3384    /**
3385     * Tries to find a revision identical to $revision in $searchLimit most recent revisions
3386     * of this page. The comparison is based on SHA1s of these revisions.
3387     *
3388     * @since 1.37
3389     *
3390     * @param RevisionRecord $revision which revision to compare to
3391     * @param int $searchLimit How many recent revisions should be checked
3392     *
3393     * @return RevisionRecord|null
3394     */
3395    public function findIdenticalRevision(
3396        RevisionRecord $revision,
3397        int $searchLimit
3398    ): ?RevisionRecord {
3399        $revision->assertWiki( $this->wikiId );
3400        $db = $this->getDBConnection( DB_REPLICA );
3401        $subquery = $this->newSelectQueryBuilder( $db )
3402            ->joinComment()
3403            ->where( [ 'rev_page' => $revision->getPageId( $this->wikiId ) ] )
3404            // Include 'rev_id' in the ordering in case there are multiple revs with same timestamp
3405            ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
3406            // T354015
3407            ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
3408            ->limit( $searchLimit )
3409            // skip the most recent edit, we can't revert to it anyway
3410            ->offset( 1 )
3411            ->caller( __METHOD__ );
3412
3413        // fetchRow effectively uses LIMIT 1 clause, returning only the first result
3414        $revisionRow = $db->newSelectQueryBuilder()
3415            ->select( '*' )
3416            ->from( $subquery, 'recent_revs' )
3417            ->where( [ 'rev_sha1' => $revision->getSha1() ] )
3418            ->caller( __METHOD__ )->fetchRow();
3419
3420        return $revisionRow ? $this->newRevisionFromRow( $revisionRow ) : null;
3421    }
3422
3423    // TODO: move relevant methods from Title here, e.g. isBigDeletion, etc.
3424}