Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.25% covered (danger)
49.25%
693 / 1407
30.14% covered (danger)
30.14%
22 / 73
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStore
49.25% covered (danger)
49.25%
693 / 1407
30.14% covered (danger)
30.14%
22 / 73
12868.44
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
 getReplicaConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrimaryConnection
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 / 70
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 / 21
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 / 55
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 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 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.19% covered (warning)
89.19%
33 / 37
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%
25 / 25
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 Content;
29use DBAccessObjectUtils;
30use IDBAccessObject;
31use InvalidArgumentException;
32use LogicException;
33use MediaWiki\CommentStore\CommentStore;
34use MediaWiki\CommentStore\CommentStoreComment;
35use MediaWiki\Content\FallbackContent;
36use MediaWiki\Content\IContentHandlerFactory;
37use MediaWiki\DAO\WikiAwareEntity;
38use MediaWiki\HookContainer\HookContainer;
39use MediaWiki\HookContainer\HookRunner;
40use MediaWiki\Linker\LinkTarget;
41use MediaWiki\Page\LegacyArticleIdAccess;
42use MediaWiki\Page\PageIdentity;
43use MediaWiki\Page\PageIdentityValue;
44use MediaWiki\Page\PageStore;
45use MediaWiki\Permissions\Authority;
46use MediaWiki\Storage\BadBlobException;
47use MediaWiki\Storage\BlobAccessException;
48use MediaWiki\Storage\BlobStore;
49use MediaWiki\Storage\NameTableAccessException;
50use MediaWiki\Storage\NameTableStore;
51use MediaWiki\Storage\RevisionSlotsUpdate;
52use MediaWiki\Storage\SqlBlobStore;
53use MediaWiki\Title\Title;
54use MediaWiki\Title\TitleFactory;
55use MediaWiki\User\ActorStore;
56use MediaWiki\User\UserIdentity;
57use MediaWiki\Utils\MWTimestamp;
58use MWException;
59use MWUnknownContentModelException;
60use Psr\Log\LoggerAwareInterface;
61use Psr\Log\LoggerInterface;
62use Psr\Log\NullLogger;
63use RecentChange;
64use RuntimeException;
65use StatusValue;
66use stdClass;
67use Traversable;
68use WANObjectCache;
69use Wikimedia\Assert\Assert;
70use Wikimedia\IPUtils;
71use Wikimedia\ObjectCache\BagOStuff;
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 IReadableDatabase
249     */
250    private function getDBConnectionRefForQueryFlags( $queryFlags ) {
251        if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
252            return $this->getPrimaryConnection();
253        } else {
254            return $this->getReplicaConnection();
255        }
256    }
257
258    /**
259     * @param string|array $groups
260     * @return IReadableDatabase
261     */
262    private function getReplicaConnection( $groups = [] ) {
263        // TODO: Replace with ICP
264        return $this->loadBalancer->getConnection( DB_REPLICA, $groups, $this->wikiId );
265    }
266
267    private function getPrimaryConnection(): IDatabase {
268        // TODO: Replace with ICP
269        return $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
270    }
271
272    /**
273     * Determines the page Title based on the available information.
274     *
275     * MCR migration note: this corresponded to Revision::getTitle
276     *
277     * @deprecated since 1.36, Use RevisionRecord::getPage() instead.
278     * @note The resulting Title object will be misleading if the RevisionStore is not
279     *        for the local wiki.
280     *
281     * @param int|null $pageId
282     * @param int|null $revId
283     * @param int $queryFlags
284     *
285     * @return Title
286     * @throws RevisionAccessException
287     */
288    public function getTitle( $pageId, $revId, $queryFlags = IDBAccessObject::READ_NORMAL ) {
289        // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069
290        if ( $this->wikiId !== WikiAwareEntity::LOCAL ) {
291            wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' );
292        }
293
294        $page = $this->getPage( $pageId, $revId, $queryFlags );
295        return $this->titleFactory->newFromPageIdentity( $page );
296    }
297
298    /**
299     * Determines the page based on the available information.
300     *
301     * @param int|null $pageId
302     * @param int|null $revId
303     * @param int $queryFlags
304     *
305     * @return PageIdentity
306     * @throws RevisionAccessException
307     */
308    private function getPage( ?int $pageId, ?int $revId, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
309        if ( !$pageId && !$revId ) {
310            throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
311        }
312
313        // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
314        // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
315        if ( DBAccessObjectUtils::hasFlags( $queryFlags, IDBAccessObject::READ_LATEST_IMMUTABLE ) ) {
316            $queryFlags = IDBAccessObject::READ_NORMAL;
317        }
318
319        // Loading by ID is best
320        if ( $pageId !== null && $pageId > 0 ) {
321            $page = $this->pageStore->getPageById( $pageId, $queryFlags );
322            if ( $page ) {
323                return $this->wrapPage( $page );
324            }
325        }
326
327        // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
328        if ( $revId !== null && $revId > 0 ) {
329            $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags )
330                ->join( 'revision', null, 'page_id=rev_page' )
331                ->conds( [ 'rev_id' => $revId ] )
332                ->caller( __METHOD__ );
333
334            $page = $pageQuery->fetchPageRecord();
335            if ( $page ) {
336                return $this->wrapPage( $page );
337            }
338        }
339
340        // If we still don't have a title, fallback to primary DB if that wasn't already happening.
341        if ( $queryFlags === IDBAccessObject::READ_NORMAL ) {
342            $title = $this->getPage( $pageId, $revId, IDBAccessObject::READ_LATEST );
343            if ( $title ) {
344                $this->logger->info(
345                    __METHOD__ . ' fell back to READ_LATEST and got a Title.',
346                    [ 'exception' => new RuntimeException() ]
347                );
348                return $title;
349            }
350        }
351
352        throw new RevisionAccessException(
353            'Could not determine title for page ID {page_id} and revision ID {rev_id}',
354            [
355                'page_id' => $pageId,
356                'rev_id' => $revId,
357            ]
358        );
359    }
360
361    /**
362     * @param PageIdentity $page
363     *
364     * @return PageIdentity
365     */
366    private function wrapPage( PageIdentity $page ): PageIdentity {
367        if ( $this->wikiId === WikiAwareEntity::LOCAL ) {
368            // NOTE: since there is still a lot of code that needs a full Title,
369            //       and uses Title::castFromPageIdentity() to get one, it's beneficial
370            //       to create a Title right away if we can, so we don't have to convert
371            //       over and over later on.
372            //       When there is less need to convert to Title, this special case can
373            //       be removed.
374            return $this->titleFactory->newFromPageIdentity( $page );
375        } else {
376            return $page;
377        }
378    }
379
380    /**
381     * @param mixed $value
382     * @param string $name
383     *
384     * @throws IncompleteRevisionException if $value is null
385     * @return mixed $value, if $value is not null
386     */
387    private function failOnNull( $value, $name ) {
388        if ( $value === null ) {
389            throw new IncompleteRevisionException(
390                "$name must not be " . var_export( $value, true ) . "!"
391            );
392        }
393
394        return $value;
395    }
396
397    /**
398     * @param mixed $value
399     * @param string $name
400     *
401     * @throws IncompleteRevisionException if $value is empty
402     * @return mixed $value, if $value is not null
403     */
404    private function failOnEmpty( $value, $name ) {
405        if ( $value === null || $value === 0 || $value === '' ) {
406            throw new IncompleteRevisionException(
407                "$name must not be " . var_export( $value, true ) . "!"
408            );
409        }
410
411        return $value;
412    }
413
414    /**
415     * Insert a new revision into the database, returning the new revision record
416     * on success and dies horribly on failure.
417     *
418     * This should be followed up by a WikiPage::updateRevisionOn on call to update
419     * page_latest on the page the revision is added to.
420     *
421     * MCR migration note: this replaced Revision::insertOn
422     *
423     * @param RevisionRecord $rev
424     * @param IDatabase $dbw (primary connection)
425     *
426     * @return RevisionRecord the new revision record.
427     */
428    public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
429        // TODO: pass in a DBTransactionContext instead of a database connection.
430        $this->checkDatabaseDomain( $dbw );
431
432        $slotRoles = $rev->getSlotRoles();
433
434        // Make sure the main slot is always provided throughout migration
435        if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
436            throw new IncompleteRevisionException(
437                'main slot must be provided'
438            );
439        }
440
441        // Checks
442        $this->failOnNull( $rev->getSize(), 'size field' );
443        $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
444        $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
445        $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
446        $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
447        $this->failOnNull( $user->getId(), 'user field' );
448        $this->failOnEmpty( $user->getName(), 'user_text field' );
449
450        if ( !$rev->isReadyForInsertion() ) {
451            // This is here for future-proofing. At the time this check being added, it
452            // was redundant to the individual checks above.
453            throw new IncompleteRevisionException( 'Revision is incomplete' );
454        }
455
456        if ( $slotRoles == [ SlotRecord::MAIN ] ) {
457            // T239717: If the main slot is the only slot, make sure the revision's nominal size
458            // and hash match the main slot's nominal size and hash.
459            $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
460            Assert::precondition(
461                $mainSlot->getSize() === $rev->getSize(),
462                'The revisions\'s size must match the main slot\'s size (see T239717)'
463            );
464            Assert::precondition(
465                $mainSlot->getSha1() === $rev->getSha1(),
466                'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)'
467            );
468        }
469
470        $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early
471
472        $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev );
473
474        /** @var RevisionRecord $rev */
475        $rev = $dbw->doAtomicSection(
476            __METHOD__,
477            function ( IDatabase $dbw, $fname ) use (
478                $rev,
479                $user,
480                $comment,
481                $pageId,
482                $parentId
483            ) {
484                return $this->insertRevisionInternal(
485                    $rev,
486                    $dbw,
487                    $user,
488                    $comment,
489                    $rev->getPage(),
490                    $pageId,
491                    $parentId
492                );
493            }
494        );
495
496        Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' );
497        Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' );
498        Assert::postcondition(
499            $rev->getComment( RevisionRecord::RAW ) !== null,
500            'revision must have a comment'
501        );
502        Assert::postcondition(
503            $rev->getUser( RevisionRecord::RAW ) !== null,
504            'revision must have a user'
505        );
506
507        // Trigger exception if the main slot is missing.
508        // Technically, this could go away after MCR migration: while
509        // calling code may require a main slot to exist, RevisionStore
510        // really should not know or care about that requirement.
511        $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
512
513        foreach ( $slotRoles as $role ) {
514            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
515            Assert::postcondition(
516                $slot->getContent() !== null,
517                $role . ' slot must have content'
518            );
519            Assert::postcondition(
520                $slot->hasRevision(),
521                $role . ' slot must have a revision associated'
522            );
523        }
524
525        $this->hookRunner->onRevisionRecordInserted( $rev );
526
527        return $rev;
528    }
529
530    /**
531     * Update derived slots in an existing revision into the database, returning the modified
532     * slots on success.
533     *
534     * @param RevisionRecord $revision After this method returns, the $revision object will be
535     *                                 obsolete in that it does not have the new slots.
536     * @param RevisionSlotsUpdate $revisionSlotsUpdate
537     * @param IDatabase $dbw (primary connection)
538     *
539     * @return SlotRecord[] the new slot records.
540     * @internal
541     */
542    public function updateSlotsOn(
543        RevisionRecord $revision,
544        RevisionSlotsUpdate $revisionSlotsUpdate,
545        IDatabase $dbw
546    ): array {
547        $this->checkDatabaseDomain( $dbw );
548
549        // Make sure all modified and removed slots are derived slots
550        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
551            Assert::precondition(
552                $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(),
553                'Trying to modify a slot that is not derived'
554            );
555        }
556        foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) {
557            $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
558            Assert::precondition(
559                $isDerived,
560                'Trying to remove a slot that is not derived'
561            );
562            throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' );
563        }
564
565        /** @var SlotRecord[] $slotRecords */
566        $slotRecords = $dbw->doAtomicSection(
567            __METHOD__,
568            function ( IDatabase $dbw, $fname ) use (
569                $revision,
570                $revisionSlotsUpdate
571            ) {
572                return $this->updateSlotsInternal(
573                    $revision,
574                    $revisionSlotsUpdate,
575                    $dbw
576                );
577            }
578        );
579
580        foreach ( $slotRecords as $role => $slot ) {
581            Assert::postcondition(
582                $slot->getContent() !== null,
583                $role . ' slot must have content'
584            );
585            Assert::postcondition(
586                $slot->hasRevision(),
587                $role . ' slot must have a revision associated'
588            );
589        }
590
591        return $slotRecords;
592    }
593
594    /**
595     * @param RevisionRecord $revision
596     * @param RevisionSlotsUpdate $revisionSlotsUpdate
597     * @param IDatabase $dbw
598     * @return SlotRecord[]
599     */
600    private function updateSlotsInternal(
601        RevisionRecord $revision,
602        RevisionSlotsUpdate $revisionSlotsUpdate,
603        IDatabase $dbw
604    ): array {
605        $page = $revision->getPage();
606        $revId = $revision->getId( $this->wikiId );
607        $blobHints = [
608            BlobStore::PAGE_HINT => $page->getId( $this->wikiId ),
609            BlobStore::REVISION_HINT => $revId,
610            BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ),
611        ];
612
613        $newSlots = [];
614        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
615            $slot = $revisionSlotsUpdate->getModifiedSlot( $role );
616            $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints );
617        }
618
619        return $newSlots;
620    }
621
622    private function insertRevisionInternal(
623        RevisionRecord $rev,
624        IDatabase $dbw,
625        UserIdentity $user,
626        CommentStoreComment $comment,
627        PageIdentity $page,
628        $pageId,
629        $parentId
630    ) {
631        $slotRoles = $rev->getSlotRoles();
632
633        $revisionRow = $this->insertRevisionRowOn(
634            $dbw,
635            $rev,
636            $parentId
637        );
638
639        $revisionId = $revisionRow['rev_id'];
640
641        $blobHints = [
642            BlobStore::PAGE_HINT => $pageId,
643            BlobStore::REVISION_HINT => $revisionId,
644            BlobStore::PARENT_HINT => $parentId,
645        ];
646
647        $newSlots = [];
648        foreach ( $slotRoles as $role ) {
649            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
650
651            // If the SlotRecord already has a revision ID set, this means it already exists
652            // in the database, and should already belong to the current revision.
653            // However, a slot may already have a revision, but no content ID, if the slot
654            // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
655            // mode, and the respective archive row was not yet migrated to the new schema.
656            // In that case, a new slot row (and content row) must be inserted even during
657            // undeletion.
658            if ( $slot->hasRevision() && $slot->hasContentId() ) {
659                // TODO: properly abort transaction if the assertion fails!
660                Assert::parameter(
661                    $slot->getRevision() === $revisionId,
662                    'slot role ' . $slot->getRole(),
663                    'Existing slot should belong to revision '
664                    . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
665                );
666
667                // Slot exists, nothing to do, move along.
668                // This happens when restoring archived revisions.
669
670                $newSlots[$role] = $slot;
671            } else {
672                $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints );
673            }
674        }
675
676        $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
677
678        $rev = new RevisionStoreRecord(
679            $page,
680            $user,
681            $comment,
682            (object)$revisionRow,
683            new RevisionSlots( $newSlots ),
684            $this->wikiId
685        );
686
687        return $rev;
688    }
689
690    /**
691     * @param IDatabase $dbw
692     * @param int $revisionId
693     * @param SlotRecord $protoSlot
694     * @param PageIdentity $page
695     * @param array $blobHints See the BlobStore::XXX_HINT constants
696     * @return SlotRecord
697     */
698    private function insertSlotOn(
699        IDatabase $dbw,
700        $revisionId,
701        SlotRecord $protoSlot,
702        PageIdentity $page,
703        array $blobHints = []
704    ) {
705        if ( $protoSlot->hasAddress() ) {
706            $blobAddress = $protoSlot->getAddress();
707        } else {
708            $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints );
709        }
710
711        if ( $protoSlot->hasContentId() ) {
712            $contentId = $protoSlot->getContentId();
713        } else {
714            $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
715        }
716
717        $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
718
719        return SlotRecord::newSaved(
720            $revisionId,
721            $contentId,
722            $blobAddress,
723            $protoSlot
724        );
725    }
726
727    /**
728     * Insert IP revision into ip_changes for use when querying for a range.
729     * @param IDatabase $dbw
730     * @param UserIdentity $user
731     * @param RevisionRecord $rev
732     * @param int $revisionId
733     */
734    private function insertIpChangesRow(
735        IDatabase $dbw,
736        UserIdentity $user,
737        RevisionRecord $rev,
738        $revisionId
739    ) {
740        if ( !$user->isRegistered() && IPUtils::isValid( $user->getName() ) ) {
741            $dbw->newInsertQueryBuilder()
742                ->insertInto( 'ip_changes' )
743                ->row( [
744                    'ipc_rev_id'        => $revisionId,
745                    'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
746                    'ipc_hex'           => IPUtils::toHex( $user->getName() ),
747                ] )
748                ->caller( __METHOD__ )->execute();
749
750        }
751    }
752
753    /**
754     * @param IDatabase $dbw
755     * @param RevisionRecord $rev
756     * @param int $parentId
757     *
758     * @return array a revision table row
759     *
760     * @throws MWException
761     * @throws MWUnknownContentModelException
762     */
763    private function insertRevisionRowOn(
764        IDatabase $dbw,
765        RevisionRecord $rev,
766        $parentId
767    ) {
768        $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId );
769
770        $revisionRow += $this->commentStore->insert(
771            $dbw,
772            'rev_comment',
773            $rev->getComment( RevisionRecord::RAW )
774        );
775
776        $dbw->newInsertQueryBuilder()
777            ->insertInto( 'revision' )
778            ->row( $revisionRow )
779            ->caller( __METHOD__ )->execute();
780
781        if ( !isset( $revisionRow['rev_id'] ) ) {
782            // only if auto-increment was used
783            $revisionRow['rev_id'] = intval( $dbw->insertId() );
784
785            if ( $dbw->getType() === 'mysql' ) {
786                // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
787                // auto-increment value to disk, so on server restart it might reuse IDs from deleted
788                // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
789
790                $maxRevId = intval( $dbw->newSelectQueryBuilder()
791                    ->select( 'MAX(ar_rev_id)' )
792                    ->from( 'archive' )
793                    ->caller( __METHOD__ )
794                    ->fetchField() );
795                $table = 'archive';
796                $maxRevId2 = intval( $dbw->newSelectQueryBuilder()
797                    ->select( 'MAX(slot_revision_id)' )
798                    ->from( 'slots' )
799                    ->caller( __METHOD__ )
800                    ->fetchField() );
801                if ( $maxRevId2 >= $maxRevId ) {
802                    $maxRevId = $maxRevId2;
803                    $table = 'slots';
804                }
805
806                if ( $maxRevId >= $revisionRow['rev_id'] ) {
807                    $this->logger->debug(
808                        '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
809                            . ' Trying to fix it.',
810                        [
811                            'revid' => $revisionRow['rev_id'],
812                            'table' => $table,
813                            'maxrevid' => $maxRevId,
814                        ]
815                    );
816
817                    if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
818                        throw new MWException( 'Failed to get database lock for T202032' );
819                    }
820                    $fname = __METHOD__;
821                    $dbw->onTransactionResolution(
822                        static function ( $trigger, IDatabase $dbw ) use ( $fname ) {
823                            $dbw->unlock( 'fix-for-T202032', $fname );
824                        },
825                        __METHOD__
826                    );
827
828                    $dbw->newDeleteQueryBuilder()
829                        ->deleteFrom( 'revision' )
830                        ->where( [ 'rev_id' => $revisionRow['rev_id'] ] )
831                        ->caller( __METHOD__ )->execute();
832
833                    // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
834                    // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
835                    // inserts too, though, at least on MariaDB 10.1.29.
836                    //
837                    // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
838                    // transactions in this code path thanks to the row lock from the original ->insert() above.
839                    //
840                    // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
841                    // that's for non-MySQL DBs.
842                    $row1 = $dbw->query(
843                        $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE',
844                        __METHOD__
845                    )->fetchObject();
846
847                    $row2 = $dbw->query(
848                        $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
849                            . ' FOR UPDATE',
850                        __METHOD__
851                    )->fetchObject();
852
853                    $maxRevId = max(
854                        $maxRevId,
855                        $row1 ? intval( $row1->v ) : 0,
856                        $row2 ? intval( $row2->v ) : 0
857                    );
858
859                    // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
860                    // transactions will throw a duplicate key error here. It doesn't seem worth trying
861                    // to avoid that.
862                    $revisionRow['rev_id'] = $maxRevId + 1;
863                    $dbw->newInsertQueryBuilder()
864                        ->insertInto( 'revision' )
865                        ->row( $revisionRow )
866                        ->caller( __METHOD__ )->execute();
867                }
868            }
869        }
870
871        return $revisionRow;
872    }
873
874    /**
875     * @param IDatabase $dbw
876     * @param RevisionRecord $rev
877     * @param int $parentId
878     *
879     * @return array a revision table row
880     */
881    private function getBaseRevisionRow(
882        IDatabase $dbw,
883        RevisionRecord $rev,
884        $parentId
885    ) {
886        // Record the edit in revisions
887        $revisionRow = [
888            'rev_page'       => $rev->getPageId( $this->wikiId ),
889            'rev_parent_id'  => $parentId,
890            'rev_actor'      => $this->actorStore->acquireActorId(
891                $rev->getUser( RevisionRecord::RAW ),
892                $dbw
893            ),
894            'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
895            'rev_timestamp'  => $dbw->timestamp( $rev->getTimestamp() ),
896            'rev_deleted'    => $rev->getVisibility(),
897            'rev_len'        => $rev->getSize(),
898            'rev_sha1'       => $rev->getSha1(),
899        ];
900
901        if ( $rev->getId( $this->wikiId ) !== null ) {
902            // Needed to restore revisions with their original ID
903            $revisionRow['rev_id'] = $rev->getId( $this->wikiId );
904        }
905
906        return $revisionRow;
907    }
908
909    /**
910     * @param SlotRecord $slot
911     * @param PageIdentity $page
912     * @param array $blobHints See the BlobStore::XXX_HINT constants
913     *
914     * @throws MWException
915     * @return string the blob address
916     */
917    private function storeContentBlob(
918        SlotRecord $slot,
919        PageIdentity $page,
920        array $blobHints = []
921    ) {
922        $content = $slot->getContent();
923        $format = $content->getDefaultFormat();
924        $model = $content->getModel();
925
926        $this->checkContent( $content, $page, $slot->getRole() );
927
928        return $this->blobStore->storeBlob(
929            $content->serialize( $format ),
930            // These hints "leak" some information from the higher abstraction layer to
931            // low level storage to allow for optimization.
932            array_merge(
933                $blobHints,
934                [
935                    BlobStore::DESIGNATION_HINT => 'page-content',
936                    BlobStore::ROLE_HINT => $slot->getRole(),
937                    BlobStore::SHA1_HINT => $slot->getSha1(),
938                    BlobStore::MODEL_HINT => $model,
939                    BlobStore::FORMAT_HINT => $format,
940                ]
941            )
942        );
943    }
944
945    /**
946     * @param SlotRecord $slot
947     * @param IDatabase $dbw
948     * @param int $revisionId
949     * @param int $contentId
950     */
951    private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
952        $dbw->newInsertQueryBuilder()
953            ->insertInto( 'slots' )
954            ->row( [
955                'slot_revision_id' => $revisionId,
956                'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
957                'slot_content_id' => $contentId,
958                // If the slot has a specific origin use that ID, otherwise use the ID of the revision
959                // that we just inserted.
960                'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
961            ] )
962            ->caller( __METHOD__ )->execute();
963    }
964
965    /**
966     * @param SlotRecord $slot
967     * @param IDatabase $dbw
968     * @param string $blobAddress
969     * @return int content row ID
970     */
971    private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
972        $dbw->newInsertQueryBuilder()
973            ->insertInto( 'content' )
974            ->row( [
975                'content_size' => $slot->getSize(),
976                'content_sha1' => $slot->getSha1(),
977                'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
978                'content_address' => $blobAddress,
979            ] )
980            ->caller( __METHOD__ )->execute();
981        return intval( $dbw->insertId() );
982    }
983
984    /**
985     * MCR migration note: this corresponded to Revision::checkContentModel
986     *
987     * @param Content $content
988     * @param PageIdentity $page
989     * @param string $role
990     *
991     * @throws MWException
992     * @throws MWUnknownContentModelException
993     */
994    private function checkContent( Content $content, PageIdentity $page, string $role ) {
995        // Note: may return null for revisions that have not yet been inserted
996
997        $model = $content->getModel();
998        $format = $content->getDefaultFormat();
999        $handler = $content->getContentHandler();
1000
1001        if ( !$handler->isSupportedFormat( $format ) ) {
1002            throw new MWException(
1003                "Can't use format $format with content model $model on $page role $role"
1004            );
1005        }
1006
1007        if ( !$content->isValid() ) {
1008            throw new MWException(
1009                "New content for $page role $role is not valid! Content model is $model"
1010            );
1011        }
1012    }
1013
1014    /**
1015     * Create a new null-revision for insertion into a page's
1016     * history. This will not re-save the text, but simply refer
1017     * to the text from the previous version.
1018     *
1019     * Such revisions can for instance identify page rename
1020     * operations and other such meta-modifications.
1021     *
1022     * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
1023     * to prevent a new revision from being inserted before the null revision has been written
1024     * to the database.
1025     *
1026     * MCR migration note: this replaced Revision::newNullRevision
1027     *
1028     * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
1029     * (or go away).
1030     *
1031     * @param IDatabase $dbw used for obtaining the lock on the page table row
1032     * @param PageIdentity $page the page to read from
1033     * @param CommentStoreComment $comment RevisionRecord's summary
1034     * @param bool $minor Whether the revision should be considered as minor
1035     * @param UserIdentity $user The user to attribute the revision to
1036     *
1037     * @return RevisionRecord|null RevisionRecord or null on error
1038     */
1039    public function newNullRevision(
1040        IDatabase $dbw,
1041        PageIdentity $page,
1042        CommentStoreComment $comment,
1043        $minor,
1044        UserIdentity $user
1045    ) {
1046        $this->checkDatabaseDomain( $dbw );
1047
1048        $pageId = $this->getArticleId( $page );
1049
1050        // T51581: Lock the page table row to ensure no other process
1051        // is adding a revision to the page at the same time.
1052        // Avoid locking extra tables, compare T191892.
1053        $pageLatest = $dbw->newSelectQueryBuilder()
1054            ->select( 'page_latest' )
1055            ->forUpdate()
1056            ->from( 'page' )
1057            ->where( [ 'page_id' => $pageId ] )
1058            ->caller( __METHOD__ )->fetchField();
1059
1060        if ( !$pageLatest ) {
1061            $msg = 'T235589: Failed to select table row during null revision creation' .
1062                " Page id '$pageId' does not exist.";
1063            $this->logger->error(
1064                $msg,
1065                [ 'exception' => new RuntimeException( $msg ) ]
1066            );
1067
1068            return null;
1069        }
1070
1071        // Fetch the actual revision row from primary DB, without locking all extra tables.
1072        $oldRevision = $this->loadRevisionFromConds(
1073            $dbw,
1074            [ 'rev_id' => intval( $pageLatest ) ],
1075            IDBAccessObject::READ_LATEST,
1076            $page
1077        );
1078
1079        if ( !$oldRevision ) {
1080            $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1081            $this->logger->error(
1082                $msg,
1083                [ 'exception' => new RuntimeException( $msg ) ]
1084            );
1085            return null;
1086        }
1087
1088        // Construct the new revision
1089        $timestamp = MWTimestamp::now( TS_MW );
1090        $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1091
1092        $newRevision->setComment( $comment );
1093        $newRevision->setUser( $user );
1094        $newRevision->setTimestamp( $timestamp );
1095        $newRevision->setMinorEdit( $minor );
1096
1097        return $newRevision;
1098    }
1099
1100    /**
1101     * MCR migration note: this replaced Revision::isUnpatrolled
1102     *
1103     * @todo This is overly specific, so move or kill this method.
1104     *
1105     * @param RevisionRecord $rev
1106     *
1107     * @return int Rcid of the unpatrolled row, zero if there isn't one
1108     */
1109    public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1110        $rc = $this->getRecentChange( $rev );
1111        if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1112            return $rc->getAttribute( 'rc_id' );
1113        } else {
1114            return 0;
1115        }
1116    }
1117
1118    /**
1119     * Get the RC object belonging to the current revision, if there's one
1120     *
1121     * MCR migration note: this replaced Revision::getRecentChange
1122     *
1123     * @todo move this somewhere else?
1124     *
1125     * @param RevisionRecord $rev
1126     * @param int $flags (optional) $flags include:
1127     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1128     *
1129     * @return null|RecentChange
1130     */
1131    public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1132        if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
1133            $dbType = DB_PRIMARY;
1134        } else {
1135            $dbType = DB_REPLICA;
1136        }
1137
1138        $rc = RecentChange::newFromConds(
1139            [
1140                'rc_this_oldid' => $rev->getId( $this->wikiId ),
1141                // rc_this_oldid does not have to be unique,
1142                // in particular, it is shared with categorization
1143                // changes. Prefer the original change because callers
1144                // often expect a change for patrolling.
1145                'rc_type' => [ RC_EDIT, RC_NEW, RC_LOG ],
1146            ],
1147            __METHOD__,
1148            $dbType
1149        );
1150
1151        // XXX: cache this locally? Glue it to the RevisionRecord?
1152        return $rc;
1153    }
1154
1155    /**
1156     * Loads a Content object based on a slot row.
1157     *
1158     * This method does not call $slot->getContent(), and may be used as a callback
1159     * called by $slot->getContent().
1160     *
1161     * MCR migration note: this roughly corresponded to Revision::getContentInternal
1162     *
1163     * @param SlotRecord $slot The SlotRecord to load content for
1164     * @param string|null $blobData The content blob, in the form indicated by $blobFlags
1165     * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
1166     *        Use null if no processing should happen. That is in contrast to the empty string,
1167     *        which causes the blob to be decoded according to the configured legacy encoding.
1168     * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
1169     * @param int $queryFlags
1170     *
1171     * @throws RevisionAccessException
1172     * @return Content
1173     */
1174    private function loadSlotContent(
1175        SlotRecord $slot,
1176        ?string $blobData = null,
1177        ?string $blobFlags = null,
1178        ?string $blobFormat = null,
1179        int $queryFlags = 0
1180    ) {
1181        if ( $blobData !== null ) {
1182            $blobAddress = $slot->hasAddress() ? $slot->getAddress() : null;
1183
1184            if ( $blobFlags === null ) {
1185                // No blob flags, so use the blob verbatim.
1186                $data = $blobData;
1187            } else {
1188                try {
1189                    $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress );
1190                } catch ( BadBlobException $e ) {
1191                    throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1192                }
1193
1194                if ( $data === false ) {
1195                    throw new RevisionAccessException(
1196                        'Failed to expand blob data using flags {flags} (key: {cache_key})',
1197                        [
1198                            'flags' => $blobFlags,
1199                            'cache_key' => $blobAddress,
1200                        ]
1201                    );
1202                }
1203            }
1204
1205        } else {
1206            $address = $slot->getAddress();
1207            try {
1208                $data = $this->blobStore->getBlob( $address, $queryFlags );
1209            } catch ( BadBlobException $e ) {
1210                throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1211            } catch ( BlobAccessException $e ) {
1212                throw new RevisionAccessException(
1213                    'Failed to load data blob from {address} for revision {revision}. '
1214                        . 'If this problem persist, use the findBadBlobs maintenance script '
1215                        . 'to investigate the issue and mark bad blobs.',
1216                    [ 'address' => $e->getMessage(), 'revision' => $slot->getRevision() ],
1217                    0,
1218                    $e
1219                );
1220            }
1221        }
1222
1223        $model = $slot->getModel();
1224
1225        // If the content model is not known, don't fail here (T220594, T220793, T228921)
1226        if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) {
1227            $this->logger->warning(
1228                "Undefined content model '$model', falling back to FallbackContent",
1229                [
1230                    'content_address' => $slot->getAddress(),
1231                    'rev_id' => $slot->getRevision(),
1232                    'role_name' => $slot->getRole(),
1233                    'model_name' => $model,
1234                    'exception' => new RuntimeException()
1235                ]
1236            );
1237
1238            return new FallbackContent( $data, $model );
1239        }
1240
1241        return $this->contentHandlerFactory
1242            ->getContentHandler( $model )
1243            ->unserializeContent( $data, $blobFormat );
1244    }
1245
1246    /**
1247     * Load a page revision from a given revision ID number.
1248     * Returns null if no such revision can be found.
1249     *
1250     * MCR migration note: this replaced Revision::newFromId
1251     *
1252     * $flags include:
1253     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1254     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1255     *
1256     * @param int $id
1257     * @param int $flags (optional)
1258     * @param PageIdentity|null $page The page the revision belongs to.
1259     *        Providing the page may improve performance.
1260     *
1261     * @return RevisionRecord|null
1262     */
1263    public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) {
1264        return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page );
1265    }
1266
1267    /**
1268     * Load either the current, or a specified, revision
1269     * that's attached to a given link target. If not attached
1270     * to that link target, will return null.
1271     *
1272     * MCR migration note: this replaced Revision::newFromTitle
1273     *
1274     * $flags include:
1275     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1276     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1277     *
1278     * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36
1279     * @param int $revId (optional)
1280     * @param int $flags Bitfield (optional)
1281     * @return RevisionRecord|null
1282     */
1283    public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) {
1284        $conds = [
1285            'page_namespace' => $page->getNamespace(),
1286            'page_title' => $page->getDBkey()
1287        ];
1288
1289        if ( $page instanceof LinkTarget ) {
1290            // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1291            $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1292        }
1293
1294        if ( $revId ) {
1295            // Use the specified revision ID.
1296            // Note that we use newRevisionFromConds here because we want to retry
1297            // and fall back to primary DB if the page is not found on a replica.
1298            // Since the caller supplied a revision ID, we are pretty sure the revision is
1299            // supposed to exist, so we should try hard to find it.
1300            $conds['rev_id'] = $revId;
1301            return $this->newRevisionFromConds( $conds, $flags, $page );
1302        } else {
1303            // Use a join to get the latest revision.
1304            // Note that we don't use newRevisionFromConds here because we don't want to retry
1305            // and fall back to primary DB. The assumption is that we only want to force the fallback
1306            // if we are quite sure the revision exists because the caller supplied a revision ID.
1307            // If the page isn't found at all on a replica, it probably simply does not exist.
1308            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1309            $conds[] = 'rev_id=page_latest';
1310            return $this->loadRevisionFromConds( $db, $conds, $flags, $page );
1311        }
1312    }
1313
1314    /**
1315     * Load either the current, or a specified, revision
1316     * that's attached to a given page ID.
1317     * Returns null if no such revision can be found.
1318     *
1319     * MCR migration note: this replaced Revision::newFromPageId
1320     *
1321     * $flags include:
1322     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB (since 1.20)
1323     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1324     *
1325     * @param int $pageId
1326     * @param int $revId (optional)
1327     * @param int $flags Bitfield (optional)
1328     * @return RevisionRecord|null
1329     */
1330    public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1331        $conds = [ 'page_id' => $pageId ];
1332        if ( $revId ) {
1333            // Use the specified revision ID.
1334            // Note that we use newRevisionFromConds here because we want to retry
1335            // and fall back to primary DB if the page is not found on a replica.
1336            // Since the caller supplied a revision ID, we are pretty sure the revision is
1337            // supposed to exist, so we should try hard to find it.
1338            $conds['rev_id'] = $revId;
1339            return $this->newRevisionFromConds( $conds, $flags );
1340        } else {
1341            // Use a join to get the latest revision.
1342            // Note that we don't use newRevisionFromConds here because we don't want to retry
1343            // and fall back to primary DB. The assumption is that we only want to force the fallback
1344            // if we are quite sure the revision exists because the caller supplied a revision ID.
1345            // If the page isn't found at all on a replica, it probably simply does not exist.
1346            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1347
1348            $conds[] = 'rev_id=page_latest';
1349
1350            return $this->loadRevisionFromConds( $db, $conds, $flags );
1351        }
1352    }
1353
1354    /**
1355     * Load the revision for the given title with the given timestamp.
1356     * WARNING: Timestamps may in some circumstances not be unique,
1357     * so this isn't the best key to use.
1358     *
1359     * MCR migration note: this replaced Revision::loadFromTimestamp
1360     *
1361     * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36
1362     * @param string $timestamp
1363     * @param int $flags Bitfield (optional) include:
1364     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1365     *      IDBAccessObject::READ_LOCKING: Select & lock the data from the primary DB
1366     *      Default: IDBAccessObject::READ_NORMAL
1367     * @return RevisionRecord|null
1368     */
1369    public function getRevisionByTimestamp(
1370        $page,
1371        string $timestamp,
1372        int $flags = IDBAccessObject::READ_NORMAL
1373    ): ?RevisionRecord {
1374        if ( $page instanceof LinkTarget ) {
1375            // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1376            $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1377        }
1378        $db = $this->getDBConnectionRefForQueryFlags( $flags );
1379        return $this->newRevisionFromConds(
1380            [
1381                'rev_timestamp' => $db->timestamp( $timestamp ),
1382                'page_namespace' => $page->getNamespace(),
1383                'page_title' => $page->getDBkey()
1384            ],
1385            $flags,
1386            $page
1387        );
1388    }
1389
1390    /**
1391     * @param int $revId The revision to load slots for.
1392     * @param int $queryFlags
1393     * @param PageIdentity $page
1394     *
1395     * @return SlotRecord[]
1396     */
1397    private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) {
1398        // TODO: Find a way to add NS_MODULE from Scribunto here
1399        if ( $page->getNamespace() !== NS_TEMPLATE ) {
1400            $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1401            return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1402        }
1403
1404        // TODO: These caches should not be needed. See T297147#7563670
1405        $res = $this->localCache->getWithSetCallback(
1406            $this->localCache->makeKey(
1407                'revision-slots',
1408                $page->getWikiId(),
1409                $page->getId( $page->getWikiId() ),
1410                $revId
1411            ),
1412            $this->localCache::TTL_HOUR,
1413            function () use ( $revId, $queryFlags, $page ) {
1414                return $this->cache->getWithSetCallback(
1415                    $this->cache->makeKey(
1416                        'revision-slots',
1417                        $page->getWikiId(),
1418                        $page->getId( $page->getWikiId() ),
1419                        $revId
1420                    ),
1421                    WANObjectCache::TTL_DAY,
1422                    function () use ( $revId, $queryFlags, $page ) {
1423                        $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1424                        if ( !$res ) {
1425                            // Avoid caching
1426                            return false;
1427                        }
1428                        return $res;
1429                    }
1430                );
1431            }
1432        );
1433        if ( !$res ) {
1434            $res = [];
1435        }
1436
1437        return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1438    }
1439
1440    private function loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page ): array {
1441        $revQuery = $this->getSlotsQueryInfo( [ 'content' ] );
1442
1443        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1444        $res = $db->newSelectQueryBuilder()
1445            ->queryInfo( $revQuery )
1446            ->where( [ 'slot_revision_id' => $revId ] )
1447            ->recency( $queryFlags )
1448            ->caller( __METHOD__ )->fetchResultSet();
1449
1450        if ( !$res->numRows() && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1451            // If we found no slots, try looking on the primary database (T212428, T252156)
1452            $this->logger->info(
1453                __METHOD__ . ' falling back to READ_LATEST.',
1454                [
1455                    'revid' => $revId,
1456                    'exception' => new RuntimeException(),
1457                ]
1458            );
1459            return $this->loadSlotRecordsFromDb(
1460                $revId,
1461                $queryFlags | IDBAccessObject::READ_LATEST,
1462                $page
1463            );
1464        }
1465        return iterator_to_array( $res );
1466    }
1467
1468    /**
1469     * Factory method for SlotRecords based on known slot rows.
1470     *
1471     * @param int $revId The revision to load slots for.
1472     * @param \stdClass[]|IResultWrapper $slotRows
1473     * @param int $queryFlags
1474     * @param PageIdentity $page
1475     * @param array|null $slotContents a map from blobAddress to slot
1476     *     content blob or Content object.
1477     *
1478     * @return SlotRecord[]
1479     */
1480    private function constructSlotRecords(
1481        $revId,
1482        $slotRows,
1483        $queryFlags,
1484        PageIdentity $page,
1485        $slotContents = null
1486    ) {
1487        $slots = [];
1488
1489        foreach ( $slotRows as $row ) {
1490            // Resolve role names and model names from in-memory cache, if they were not joined in.
1491            if ( !isset( $row->role_name ) ) {
1492                $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1493            }
1494
1495            if ( !isset( $row->model_name ) ) {
1496                if ( isset( $row->content_model ) ) {
1497                    $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1498                } else {
1499                    // We may get here if $row->model_name is set but null, perhaps because it
1500                    // came from rev_content_model, which is NULL for the default model.
1501                    $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1502                    $row->model_name = $slotRoleHandler->getDefaultModel( $page );
1503                }
1504            }
1505
1506            // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1507            if ( isset( $row->blob_data ) ) {
1508                $slotContents[$row->content_address] = $row->blob_data;
1509            }
1510
1511            $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1512                $blob = null;
1513                if ( isset( $slotContents[$slot->getAddress()] ) ) {
1514                    $blob = $slotContents[$slot->getAddress()];
1515                    if ( $blob instanceof Content ) {
1516                        return $blob;
1517                    }
1518                }
1519                return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1520            };
1521
1522            $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1523        }
1524
1525        if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1526            $this->logger->error(
1527                __METHOD__ . ': Main slot of revision not found in database. See T212428.',
1528                [
1529                    'revid' => $revId,
1530                    'queryFlags' => $queryFlags,
1531                    'exception' => new RuntimeException(),
1532                ]
1533            );
1534
1535            throw new RevisionAccessException(
1536                'Main slot of revision not found in database. See T212428.'
1537            );
1538        }
1539
1540        return $slots;
1541    }
1542
1543    /**
1544     * Factory method for RevisionSlots based on a revision ID.
1545     *
1546     * @note If other code has a need to construct RevisionSlots objects, this should be made
1547     * public, since RevisionSlots instances should not be constructed directly.
1548     *
1549     * @param int $revId
1550     * @param \stdClass[]|null $slotRows
1551     * @param int $queryFlags
1552     * @param PageIdentity $page
1553     *
1554     * @return RevisionSlots
1555     */
1556    private function newRevisionSlots(
1557        $revId,
1558        $slotRows,
1559        $queryFlags,
1560        PageIdentity $page
1561    ) {
1562        if ( $slotRows ) {
1563            $slots = new RevisionSlots(
1564                $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page )
1565            );
1566        } else {
1567            $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) {
1568                return $this->loadSlotRecords( $revId, $queryFlags, $page );
1569            } );
1570        }
1571
1572        return $slots;
1573    }
1574
1575    /**
1576     * Make a fake RevisionRecord object from an archive table row. This is queried
1577     * for permissions or even inserted (as in Special:Undelete)
1578     *
1579     * The user ID and user name may optionally be supplied using the aliases
1580     * ar_user and ar_user_text (the names of fields which existed before
1581     * MW 1.34).
1582     *
1583     * MCR migration note: this replaced Revision::newFromArchiveRow
1584     *
1585     * @param \stdClass $row
1586     * @param int $queryFlags
1587     * @param PageIdentity|null $page
1588     * @param array $overrides associative array with fields of $row to override. This may be
1589     *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
1590     *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
1591     *   override ar_parent_id.
1592     *
1593     * @return RevisionRecord
1594     */
1595    public function newRevisionFromArchiveRow(
1596        $row,
1597        $queryFlags = 0,
1598        PageIdentity $page = null,
1599        array $overrides = []
1600    ) {
1601        return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides );
1602    }
1603
1604    /**
1605     * @see RevisionFactory::newRevisionFromRow
1606     *
1607     * MCR migration note: this replaced Revision::newFromRow
1608     *
1609     * @param \stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1610     * @param int $queryFlags
1611     * @param PageIdentity|null $page Preloaded page object
1612     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1613     *   data is returned from getters, by querying the database as needed
1614     * @return RevisionRecord
1615     */
1616    public function newRevisionFromRow(
1617        $row,
1618        $queryFlags = 0,
1619        PageIdentity $page = null,
1620        $fromCache = false
1621    ) {
1622        return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache );
1623    }
1624
1625    /**
1626     * @see newRevisionFromArchiveRow()
1627     * @since 1.35
1628     *
1629     * @param stdClass $row
1630     * @param null|stdClass[]|RevisionSlots $slots
1631     *  - Database rows generated from a query based on getSlotsQueryInfo
1632     *    with the 'content' flag set. Or
1633     *  - RevisionSlots instance
1634     * @param int $queryFlags
1635     * @param PageIdentity|null $page
1636     * @param array $overrides associative array with fields of $row to override. This may be
1637     *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
1638     *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
1639     *   override ar_parent_id.
1640     *
1641     * @return RevisionRecord
1642     */
1643    public function newRevisionFromArchiveRowAndSlots(
1644        stdClass $row,
1645        $slots,
1646        int $queryFlags = 0,
1647        ?PageIdentity $page = null,
1648        array $overrides = []
1649    ) {
1650        if ( !$page && isset( $overrides['title'] ) ) {
1651            if ( !( $overrides['title'] instanceof PageIdentity ) ) {
1652                throw new InvalidArgumentException( 'title field override must contain a PageIdentity object.' );
1653            }
1654
1655            $page = $overrides['title'];
1656        }
1657
1658        if ( !isset( $page ) ) {
1659            if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1660                $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1661            } else {
1662                throw new InvalidArgumentException(
1663                    'A Title or ar_namespace and ar_title must be given'
1664                );
1665            }
1666        }
1667
1668        foreach ( $overrides as $key => $value ) {
1669            $field = "ar_$key";
1670            $row->$field = $value;
1671        }
1672
1673        try {
1674            $user = $this->actorStore->newActorFromRowFields(
1675                $row->ar_user ?? null,
1676                $row->ar_user_text ?? null,
1677                $row->ar_actor ?? null
1678            );
1679        } catch ( InvalidArgumentException $ex ) {
1680            $this->logger->warning( 'Could not load user for archive revision {rev_id}', [
1681                'ar_rev_id' => $row->ar_rev_id,
1682                'ar_actor' => $row->ar_actor ?? 'null',
1683                'ar_user_text' => $row->ar_user_text ?? 'null',
1684                'ar_user' => $row->ar_user ?? 'null',
1685                'exception' => $ex
1686            ] );
1687            $user = $this->actorStore->getUnknownActor();
1688        }
1689
1690        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1691        // Legacy because $row may have come from self::selectFields()
1692        $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1693
1694        if ( !( $slots instanceof RevisionSlots ) ) {
1695            $slots = $this->newRevisionSlots( (int)$row->ar_rev_id, $slots, $queryFlags, $page );
1696        }
1697        return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId );
1698    }
1699
1700    /**
1701     * @see newFromRevisionRow()
1702     *
1703     * @param stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1704     * @param null|stdClass[]|RevisionSlots $slots
1705     *  - Database rows generated from a query based on getSlotsQueryInfo
1706     *    with the 'content' flag set. Or
1707     *  - RevisionSlots instance
1708     * @param int $queryFlags
1709     * @param PageIdentity|null $page
1710     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1711     *   data is returned from getters, by querying the database as needed
1712     *
1713     * @return RevisionRecord
1714     * @throws RevisionAccessException
1715     * @see RevisionFactory::newRevisionFromRow
1716     */
1717    public function newRevisionFromRowAndSlots(
1718        stdClass $row,
1719        $slots,
1720        int $queryFlags = 0,
1721        ?PageIdentity $page = null,
1722        bool $fromCache = false
1723    ) {
1724        if ( !$page ) {
1725            if ( isset( $row->page_id )
1726                && isset( $row->page_namespace )
1727                && isset( $row->page_title )
1728            ) {
1729                $page = new PageIdentityValue(
1730                    (int)$row->page_id,
1731                    (int)$row->page_namespace,
1732                    $row->page_title,
1733                    $this->wikiId
1734                );
1735
1736                $page = $this->wrapPage( $page );
1737            } else {
1738                $pageId = (int)( $row->rev_page ?? 0 );
1739                $revId = (int)( $row->rev_id ?? 0 );
1740
1741                $page = $this->getPage( $pageId, $revId, $queryFlags );
1742            }
1743        } else {
1744            $page = $this->ensureRevisionRowMatchesPage( $row, $page );
1745        }
1746
1747        if ( !$page ) {
1748            // This should already have been caught about, but apparently
1749            // it not always is, see T286877.
1750            throw new RevisionAccessException(
1751                "Failed to determine page associated with revision {$row->rev_id}"
1752            );
1753        }
1754
1755        try {
1756            $user = $this->actorStore->newActorFromRowFields(
1757                $row->rev_user ?? null,
1758                $row->rev_user_text ?? null,
1759                $row->rev_actor ?? null
1760            );
1761        } catch ( InvalidArgumentException $ex ) {
1762            $this->logger->warning( 'Could not load user for revision {rev_id}', [
1763                'rev_id' => $row->rev_id,
1764                'rev_actor' => $row->rev_actor ?? 'null',
1765                'rev_user_text' => $row->rev_user_text ?? 'null',
1766                'rev_user' => $row->rev_user ?? 'null',
1767                'exception' => $ex
1768            ] );
1769            $user = $this->actorStore->getUnknownActor();
1770        }
1771
1772        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1773        // Legacy because $row may have come from self::selectFields()
1774        $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1775
1776        if ( !( $slots instanceof RevisionSlots ) ) {
1777            $slots = $this->newRevisionSlots( (int)$row->rev_id, $slots, $queryFlags, $page );
1778        }
1779
1780        // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data.
1781        if ( $fromCache ) {
1782            $rev = new RevisionStoreCacheRecord(
1783                function ( $revId ) use ( $queryFlags ) {
1784                    $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1785                    $row = $this->fetchRevisionRowFromConds(
1786                        $db,
1787                        [ 'rev_id' => intval( $revId ) ]
1788                    );
1789                    if ( !$row && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1790                        // If we found no slots, try looking on the primary database (T259738)
1791                        $this->logger->info(
1792                            'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.',
1793                            [
1794                                'revid' => $revId,
1795                                'exception' => new RuntimeException(),
1796                            ]
1797                        );
1798                        $dbw = $this->getDBConnectionRefForQueryFlags( IDBAccessObject::READ_LATEST );
1799                        $row = $this->fetchRevisionRowFromConds(
1800                            $dbw,
1801                            [ 'rev_id' => intval( $revId ) ]
1802                        );
1803                    }
1804                    if ( !$row ) {
1805                        return [ null, null ];
1806                    }
1807                    return [
1808                        $row->rev_deleted,
1809                        $this->actorStore->newActorFromRowFields(
1810                            $row->rev_user ?? null,
1811                            $row->rev_user_text ?? null,
1812                            $row->rev_actor ?? null
1813                        )
1814                    ];
1815                },
1816                $page, $user, $comment, $row, $slots, $this->wikiId
1817            );
1818        } else {
1819            $rev = new RevisionStoreRecord(
1820                $page, $user, $comment, $row, $slots, $this->wikiId );
1821        }
1822        return $rev;
1823    }
1824
1825    /**
1826     * Check that the given row matches the given Title object.
1827     * When a mismatch is detected, this tries to re-load the title from primary DB,
1828     * to avoid spurious errors during page moves.
1829     *
1830     * @param \stdClass $row
1831     * @param PageIdentity $page
1832     * @param array $context
1833     *
1834     * @return Pageidentity
1835     */
1836    private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) {
1837        $revId = (int)( $row->rev_id ?? 0 );
1838        $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id?
1839        $expectedPageId = $page->getId( $this->wikiId );
1840        // Avoid fatal error when the Title's ID changed, T246720
1841        if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) {
1842            // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here.
1843            $pageRec = $this->pageStore->getPageByName(
1844                $page->getNamespace(),
1845                $page->getDBkey(),
1846                IDBAccessObject::READ_LATEST
1847            );
1848            $masterPageId = $pageRec->getId( $this->wikiId );
1849            $masterLatest = $pageRec->getLatest( $this->wikiId );
1850            if ( $revPageId === $masterPageId ) {
1851                if ( $page instanceof Title ) {
1852                    // If we were using a Title object, keep using it, but update the page ID.
1853                    // This way, we don't unexpectedly mix Titles with immutable value objects.
1854                    $page->resetArticleID( $masterPageId );
1855
1856                } else {
1857                    $page = $pageRec;
1858                }
1859
1860                $this->logger->info(
1861                    "Encountered stale Title object",
1862                    [
1863                        'page_id_stale' => $expectedPageId,
1864