Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.47% covered (warning)
79.47%
1142 / 1437
56.00% covered (warning)
56.00%
42 / 75
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStore
79.47% covered (warning)
79.47%
1142 / 1437
56.00% covered (warning)
56.00%
42 / 75
1245.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isReadOnly
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWikiId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBConnectionRefForQueryFlags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getReplicaConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getPage
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
12.04
 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.48% covered (success)
98.48%
65 / 66
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
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
4
 insertSlotOn
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 insertIpChangesRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 insertRevisionRowOn
20.59% covered (danger)
20.59%
14 / 68
0.00% covered (danger)
0.00%
0 / 1
40.05
 getBaseRevisionRow
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 storeContentBlob
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 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
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 loadSlotContent
50.00% covered (danger)
50.00%
22 / 44
0.00% covered (danger)
0.00%
0 / 1
19.12
 getRevisionById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionByTitle
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 getRevisionByPageId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionByTimestamp
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 loadSlotRecords
9.38% covered (danger)
9.38%
3 / 32
0.00% covered (danger)
0.00%
0 / 1
15.91
 loadSlotRecordsFromDb
42.86% covered (danger)
42.86%
9 / 21
0.00% covered (danger)
0.00%
0 / 1
4.68
 constructSlotRecords
57.58% covered (warning)
57.58%
19 / 33
0.00% covered (danger)
0.00%
0 / 1
15.18
 newRevisionSlots
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 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
78.38% covered (warning)
78.38%
58 / 74
0.00% covered (danger)
0.00%
0 / 1
13.46
 ensureRevisionRowMatchesPage
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
7
 newRevisionsFromBatch
83.46% covered (warning)
83.46%
111 / 133
0.00% covered (danger)
0.00%
0 / 1
35.35
 getSlotRowsForBatch
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
19
 getContentBlobsForBatch
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 newRevisionFromConds
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 loadRevisionFromConds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkDatabaseDomain
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 fetchRevisionRowFromConds
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getQueryInfo
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
4.00
 newSelectQueryBuilder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newArchiveSelectQueryBuilder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSlotsQueryInfo
100.00% covered (success)
100.00%
33 / 33
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%
26 / 26
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
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
9.71
 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
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
3.94
 getTimestampFromId
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 countRevisionsByPageId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 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
 getKnownLatestRevision
76.19% covered (warning)
76.19%
32 / 42
0.00% covered (danger)
0.00%
0 / 1
10.09
 getKnownCurrentRevision
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFirstRevision
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
 getRevisionRowCacheKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 assertRevisionParameter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getPageConditions
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 getRevisionLimitConditions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 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%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 findIdenticalRevision
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * Attribution notice: when this file was created, much of its content was taken
5 * from the Revision.php file as present in release 1.30. Refer to the history
6 * of that file for original authorship (that file was removed entirely in 1.37,
7 * but its history can still be found in prior versions of MediaWiki).
8 *
9 * @file
10 */
11
12namespace MediaWiki\Revision;
13
14use InvalidArgumentException;
15use LogicException;
16use MediaWiki\CommentStore\CommentStore;
17use MediaWiki\CommentStore\CommentStoreComment;
18use MediaWiki\Content\Content;
19use MediaWiki\Content\FallbackContent;
20use MediaWiki\Content\IContentHandlerFactory;
21use MediaWiki\Content\UnknownContentModelException;
22use MediaWiki\DAO\WikiAwareEntity;
23use MediaWiki\Exception\MWException;
24use MediaWiki\HookContainer\HookContainer;
25use MediaWiki\HookContainer\HookRunner;
26use MediaWiki\Linker\LinkTarget;
27use MediaWiki\Page\LegacyArticleIdAccess;
28use MediaWiki\Page\PageIdentity;
29use MediaWiki\Page\PageIdentityValue;
30use MediaWiki\Page\PageReference;
31use MediaWiki\Page\PageStore;
32use MediaWiki\Permissions\Authority;
33use MediaWiki\RecentChanges\RecentChange;
34use MediaWiki\RecentChanges\RecentChangeLookup;
35use MediaWiki\Storage\BadBlobException;
36use MediaWiki\Storage\BlobAccessException;
37use MediaWiki\Storage\BlobStore;
38use MediaWiki\Storage\NameTableAccessException;
39use MediaWiki\Storage\NameTableStore;
40use MediaWiki\Storage\RevisionSlotsUpdate;
41use MediaWiki\Storage\SqlBlobStore;
42use MediaWiki\Title\Title;
43use MediaWiki\Title\TitleFactory;
44use MediaWiki\User\ActorStore;
45use MediaWiki\User\UserIdentity;
46use MediaWiki\Utils\MWTimestamp;
47use Psr\Log\LoggerAwareInterface;
48use Psr\Log\LoggerInterface;
49use Psr\Log\NullLogger;
50use RuntimeException;
51use StatusValue;
52use stdClass;
53use Traversable;
54use Wikimedia\Assert\Assert;
55use Wikimedia\Assert\ParameterAssertionException;
56use Wikimedia\Assert\PreconditionException;
57use Wikimedia\IPUtils;
58use Wikimedia\ObjectCache\BagOStuff;
59use Wikimedia\ObjectCache\WANObjectCache;
60use Wikimedia\Rdbms\Database;
61use Wikimedia\Rdbms\DBAccessObjectUtils;
62use Wikimedia\Rdbms\IDatabase;
63use Wikimedia\Rdbms\IDBAccessObject;
64use Wikimedia\Rdbms\ILoadBalancer;
65use Wikimedia\Rdbms\IReadableDatabase;
66use Wikimedia\Rdbms\IResultWrapper;
67use Wikimedia\Rdbms\Platform\ISQLPlatform;
68use Wikimedia\Rdbms\SelectQueryBuilder;
69use Wikimedia\Timestamp\TimestampFormat as TS;
70
71/**
72 * Service for looking up page revisions.
73 *
74 * @since 1.31
75 * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore
76 *
77 * @note This was written to act as a drop-in replacement for the corresponding
78 *       static methods in the old Revision class (which was later removed in 1.37).
79 */
80class RevisionStore implements RevisionFactory, RevisionLookup, LoggerAwareInterface {
81
82    use LegacyArticleIdAccess;
83
84    public const ROW_CACHE_KEY = 'revision-row-1.29';
85
86    public const ORDER_OLDEST_TO_NEWEST = 'ASC';
87    public const ORDER_NEWEST_TO_OLDEST = 'DESC';
88
89    // Constants for get(...)Between methods
90    public const INCLUDE_OLD = 'include_old';
91    public const INCLUDE_NEW = 'include_new';
92    public const INCLUDE_BOTH = 'include_both';
93    public const INCLUDE_DELETED_REVISIONS = 'include_deleted_revisions';
94
95    /**
96     * @var SqlBlobStore
97     */
98    private $blobStore;
99
100    /**
101     * @var false|string
102     */
103    private $wikiId;
104
105    private ILoadBalancer $loadBalancer;
106    private WANObjectCache $cache;
107    private BagOStuff $localCache;
108    private CommentStore $commentStore;
109    private ActorStore $actorStore;
110    private LoggerInterface $logger;
111    private NameTableStore $contentModelStore;
112    private NameTableStore $slotRoleStore;
113    private SlotRoleRegistry $slotRoleRegistry;
114    private IContentHandlerFactory $contentHandlerFactory;
115    private HookRunner $hookRunner;
116    private PageStore $pageStore;
117    private TitleFactory $titleFactory;
118    private RecentChangeLookup $recentChangeLookup;
119
120    /**
121     * @param ILoadBalancer $loadBalancer
122     * @param SqlBlobStore $blobStore
123     * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
124     *        wiki's default instance even if $wikiId refers to a different wiki, since
125     *        makeGlobalKey() is used to constructed a key that allows cached revision rows from
126     *        the same database to be re-used between wikis. For example, enwiki and frwiki will
127     *        use the same cache keys for revision rows from the wikidatawiki database, regardless
128     *        of the cache's default key space.
129     * @param BagOStuff $localCache Another layer of cache, best to use APCu here.
130     * @param CommentStore $commentStore
131     * @param NameTableStore $contentModelStore
132     * @param NameTableStore $slotRoleStore
133     * @param SlotRoleRegistry $slotRoleRegistry
134     * @param ActorStore $actorStore
135     * @param IContentHandlerFactory $contentHandlerFactory
136     * @param PageStore $pageStore
137     * @param TitleFactory $titleFactory
138     * @param HookContainer $hookContainer
139     * @param RecentChangeLookup $recentChangeLookup
140     * @param false|string $wikiId Relevant wiki id or WikiAwareEntity::LOCAL for the current one
141     *
142     * @todo $blobStore should be allowed to be any BlobStore!
143     */
144    public function __construct(
145        ILoadBalancer $loadBalancer,
146        SqlBlobStore $blobStore,
147        WANObjectCache $cache,
148        BagOStuff $localCache,
149        CommentStore $commentStore,
150        NameTableStore $contentModelStore,
151        NameTableStore $slotRoleStore,
152        SlotRoleRegistry $slotRoleRegistry,
153        ActorStore $actorStore,
154        IContentHandlerFactory $contentHandlerFactory,
155        PageStore $pageStore,
156        TitleFactory $titleFactory,
157        HookContainer $hookContainer,
158        RecentChangeLookup $recentChangeLookup,
159        $wikiId = WikiAwareEntity::LOCAL
160    ) {
161        Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' );
162
163        $this->loadBalancer = $loadBalancer;
164        $this->blobStore = $blobStore;
165        $this->cache = $cache;
166        $this->localCache = $localCache;
167        $this->commentStore = $commentStore;
168        $this->contentModelStore = $contentModelStore;
169        $this->slotRoleStore = $slotRoleStore;
170        $this->slotRoleRegistry = $slotRoleRegistry;
171        $this->actorStore = $actorStore;
172        $this->wikiId = $wikiId;
173        $this->logger = new NullLogger();
174        $this->contentHandlerFactory = $contentHandlerFactory;
175        $this->pageStore = $pageStore;
176        $this->titleFactory = $titleFactory;
177        $this->hookRunner = new HookRunner( $hookContainer );
178        $this->recentChangeLookup = $recentChangeLookup;
179    }
180
181    public function setLogger( LoggerInterface $logger ): void {
182        $this->logger = $logger;
183    }
184
185    /**
186     * @return bool Whether the store is read-only
187     */
188    public function isReadOnly() {
189        return $this->blobStore->isReadOnly();
190    }
191
192    /**
193     * Get the ID of the wiki this revision belongs to.
194     *
195     * @return string|false The wiki's logical name, of false to indicate the local wiki.
196     */
197    public function getWikiId() {
198        return $this->wikiId;
199    }
200
201    /**
202     * @param int $queryFlags a bit field composed of READ_XXX flags
203     *
204     * @return IReadableDatabase
205     */
206    private function getDBConnectionRefForQueryFlags( $queryFlags ) {
207        if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
208            return $this->getPrimaryConnection();
209        } else {
210            return $this->getReplicaConnection();
211        }
212    }
213
214    /**
215     * @param string|array $groups
216     * @return IReadableDatabase
217     */
218    private function getReplicaConnection( $groups = [] ) {
219        // TODO: Replace with ICP
220        return $this->loadBalancer->getConnection( DB_REPLICA, $groups, $this->wikiId );
221    }
222
223    private function getPrimaryConnection(): IDatabase {
224        // TODO: Replace with ICP
225        return $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
226    }
227
228    /**
229     * Determines the page Title based on the available information.
230     *
231     * MCR migration note: this corresponded to Revision::getTitle
232     *
233     * @deprecated since 1.36, Use RevisionRecord::getPage() instead.
234     * @note The resulting Title object will be misleading if the RevisionStore is not
235     *        for the local wiki.
236     *
237     * @param int|null $pageId
238     * @param int|null $revId
239     * @param int $queryFlags
240     *
241     * @return Title
242     * @throws RevisionAccessException
243     */
244    public function getTitle( $pageId, $revId, $queryFlags = IDBAccessObject::READ_NORMAL ) {
245        // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069
246        if ( $this->wikiId !== WikiAwareEntity::LOCAL ) {
247            wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' );
248        }
249
250        $page = $this->getPage( $pageId, $revId, $queryFlags );
251        return $this->titleFactory->newFromPageIdentity( $page );
252    }
253
254    /**
255     * Determines the page based on the available information.
256     *
257     * @param int|null $pageId
258     * @param int|null $revId
259     * @param int $queryFlags
260     *
261     * @return PageIdentity
262     * @throws RevisionAccessException
263     */
264    private function getPage( ?int $pageId, ?int $revId, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
265        if ( !$pageId && !$revId ) {
266            throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
267        }
268
269        // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
270        // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
271        if ( DBAccessObjectUtils::hasFlags( $queryFlags, IDBAccessObject::READ_LATEST_IMMUTABLE ) ) {
272            $queryFlags = IDBAccessObject::READ_NORMAL;
273        }
274
275        // Loading by ID is best
276        if ( $pageId !== null && $pageId > 0 ) {
277            $page = $this->pageStore->getPageById( $pageId, $queryFlags );
278            if ( $page ) {
279                return $this->wrapPage( $page );
280            }
281        }
282
283        // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
284        if ( $revId !== null && $revId > 0 ) {
285            $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags )
286                ->join( 'revision', null, 'page_id=rev_page' )
287                ->conds( [ 'rev_id' => $revId ] )
288                ->caller( __METHOD__ );
289
290            $page = $pageQuery->fetchPageRecord();
291            if ( $page ) {
292                return $this->wrapPage( $page );
293            }
294        }
295
296        // If we still don't have a title, fallback to primary DB if that wasn't already happening.
297        if ( $queryFlags === IDBAccessObject::READ_NORMAL ) {
298            $title = $this->getPage( $pageId, $revId, IDBAccessObject::READ_LATEST );
299            if ( $title ) {
300                $this->logger->info(
301                    __METHOD__ . ' fell back to READ_LATEST and got a Title.',
302                    [ 'exception' => new RuntimeException() ]
303                );
304                return $title;
305            }
306        }
307
308        throw new RevisionAccessException(
309            'Could not determine title for page ID {page_id} and revision ID {rev_id}',
310            [
311                'page_id' => $pageId,
312                'rev_id' => $revId,
313            ]
314        );
315    }
316
317    private function wrapPage( PageIdentity $page ): PageIdentity {
318        if ( $this->wikiId === WikiAwareEntity::LOCAL ) {
319            // NOTE: since there is still a lot of code that needs a full Title,
320            //       and uses Title::castFromPageIdentity() to get one, it's beneficial
321            //       to create a Title right away if we can, so we don't have to convert
322            //       over and over later on.
323            //       When there is less need to convert to Title, this special case can
324            //       be removed.
325            return $this->titleFactory->newFromPageIdentity( $page );
326        } else {
327            return $page;
328        }
329    }
330
331    /**
332     * @param mixed $value
333     * @param string $name
334     *
335     * @throws IncompleteRevisionException if $value is null
336     * @return mixed $value, if $value is not null
337     */
338    private function failOnNull( $value, $name ) {
339        if ( $value === null ) {
340            throw new IncompleteRevisionException(
341                "$name must not be " . var_export( $value, true ) . "!"
342            );
343        }
344
345        return $value;
346    }
347
348    /**
349     * @param mixed $value
350     * @param string $name
351     *
352     * @throws IncompleteRevisionException if $value is empty
353     * @return mixed $value, if $value is not null
354     */
355    private function failOnEmpty( $value, $name ) {
356        if ( $value === null || $value === 0 || $value === '' ) {
357            throw new IncompleteRevisionException(
358                "$name must not be " . var_export( $value, true ) . "!"
359            );
360        }
361
362        return $value;
363    }
364
365    /**
366     * Insert a new revision into the database, returning the new revision record
367     * on success and dies horribly on failure.
368     *
369     * This should be followed up by a WikiPage::updateRevisionOn on call to update
370     * page_latest on the page the revision is added to.
371     *
372     * MCR migration note: this replaced Revision::insertOn
373     *
374     * @param RevisionRecord $rev
375     * @param IDatabase $dbw (primary connection)
376     *
377     * @return RevisionRecord the new revision record.
378     */
379    public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
380        // TODO: pass in a DBTransactionContext instead of a database connection.
381        $this->checkDatabaseDomain( $dbw );
382
383        $slotRoles = $rev->getSlotRoles();
384
385        // Make sure the main slot is always provided throughout migration
386        if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
387            throw new IncompleteRevisionException(
388                'main slot must be provided'
389            );
390        }
391
392        // Checks
393        $this->failOnNull( $rev->getSize(), 'size field' );
394        $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
395        $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
396        $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
397        $this->failOnNull( $user->getId(), 'user field' );
398        $this->failOnEmpty( $user->getName(), 'user_text field' );
399
400        if ( !$rev->isReadyForInsertion() ) {
401            // This is here for future-proofing. At the time this check being added, it
402            // was redundant to the individual checks above.
403            throw new IncompleteRevisionException( 'Revision is incomplete' );
404        }
405
406        if ( $slotRoles == [ SlotRecord::MAIN ] ) {
407            // T239717: If the main slot is the only slot, make sure the revision's nominal size
408            // and hash match the main slot's nominal size and hash.
409            $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
410            Assert::precondition(
411                $mainSlot->getSize() === $rev->getSize(),
412                'The revisions\'s size must match the main slot\'s size (see T239717)'
413            );
414        }
415
416        $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early
417
418        $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev );
419
420        Assert::precondition( $pageId > 0, 'revision must have an ID' );
421
422        /** @var RevisionRecord $rev */
423        $rev = $dbw->doAtomicSection(
424            __METHOD__,
425            function ( IDatabase $dbw, $fname ) use (
426                $rev,
427                $user,
428                $comment,
429                $pageId,
430                $parentId
431            ) {
432                return $this->insertRevisionInternal(
433                    $rev,
434                    $dbw,
435                    $user,
436                    $comment,
437                    $rev->getPage(),
438                    $pageId,
439                    $parentId
440                );
441            }
442        );
443
444        Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' );
445        Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' );
446        Assert::postcondition(
447            $rev->getComment( RevisionRecord::RAW ) !== null,
448            'revision must have a comment'
449        );
450        Assert::postcondition(
451            $rev->getUser( RevisionRecord::RAW ) !== null,
452            'revision must have a user'
453        );
454
455        // Trigger exception if the main slot is missing.
456        // Technically, this could go away after MCR migration: while
457        // calling code may require a main slot to exist, RevisionStore
458        // really should not know or care about that requirement.
459        $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
460
461        foreach ( $slotRoles as $role ) {
462            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
463            Assert::postcondition(
464                $slot->getContent() !== null,
465                $role . ' slot must have content'
466            );
467            Assert::postcondition(
468                $slot->hasRevision(),
469                $role . ' slot must have a revision associated'
470            );
471        }
472
473        $this->hookRunner->onRevisionRecordInserted( $rev );
474
475        return $rev;
476    }
477
478    /**
479     * Update derived slots in an existing revision into the database, returning the modified
480     * slots on success.
481     *
482     * @param RevisionRecord $revision After this method returns, the $revision object will be
483     *                                 obsolete in that it does not have the new slots.
484     * @param RevisionSlotsUpdate $revisionSlotsUpdate
485     * @param IDatabase $dbw (primary connection)
486     *
487     * @return SlotRecord[] the new slot records.
488     * @internal
489     */
490    public function updateSlotsOn(
491        RevisionRecord $revision,
492        RevisionSlotsUpdate $revisionSlotsUpdate,
493        IDatabase $dbw
494    ): array {
495        $this->checkDatabaseDomain( $dbw );
496
497        // Make sure all modified and removed slots are derived slots
498        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
499            Assert::precondition(
500                $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(),
501                'Trying to modify a slot that is not derived'
502            );
503        }
504        foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) {
505            $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
506            Assert::precondition(
507                $isDerived,
508                'Trying to remove a slot that is not derived'
509            );
510            throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' );
511        }
512
513        /** @var SlotRecord[] $slotRecords */
514        $slotRecords = $dbw->doAtomicSection(
515            __METHOD__,
516            function ( IDatabase $dbw, $fname ) use (
517                $revision,
518                $revisionSlotsUpdate
519            ) {
520                return $this->updateSlotsInternal(
521                    $revision,
522                    $revisionSlotsUpdate,
523                    $dbw
524                );
525            }
526        );
527
528        foreach ( $slotRecords as $role => $slot ) {
529            Assert::postcondition(
530                $slot->getContent() !== null,
531                $role . ' slot must have content'
532            );
533            Assert::postcondition(
534                $slot->hasRevision(),
535                $role . ' slot must have a revision associated'
536            );
537        }
538
539        return $slotRecords;
540    }
541
542    /**
543     * @param RevisionRecord $revision
544     * @param RevisionSlotsUpdate $revisionSlotsUpdate
545     * @param IDatabase $dbw
546     * @return SlotRecord[]
547     */
548    private function updateSlotsInternal(
549        RevisionRecord $revision,
550        RevisionSlotsUpdate $revisionSlotsUpdate,
551        IDatabase $dbw
552    ): array {
553        $page = $revision->getPage();
554        $revId = $revision->getId( $this->wikiId );
555        $blobHints = [
556            BlobStore::PAGE_HINT => $page->getId( $this->wikiId ),
557            BlobStore::REVISION_HINT => $revId,
558            BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ),
559        ];
560
561        $newSlots = [];
562        foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
563            $slot = $revisionSlotsUpdate->getModifiedSlot( $role );
564            $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints );
565        }
566
567        return $newSlots;
568    }
569
570    private function insertRevisionInternal(
571        RevisionRecord $rev,
572        IDatabase $dbw,
573        UserIdentity $user,
574        CommentStoreComment $comment,
575        PageIdentity $page,
576        int $pageId,
577        int $parentId
578    ): RevisionRecord {
579        $slotRoles = $rev->getSlotRoles();
580
581        $revisionRow = $this->insertRevisionRowOn(
582            $dbw,
583            $rev,
584            $parentId
585        );
586
587        $revisionId = $revisionRow['rev_id'];
588
589        $blobHints = [
590            BlobStore::PAGE_HINT => $pageId,
591            BlobStore::REVISION_HINT => $revisionId,
592            BlobStore::PARENT_HINT => $parentId,
593        ];
594
595        $newSlots = [];
596        foreach ( $slotRoles as $role ) {
597            $slot = $rev->getSlot( $role, RevisionRecord::RAW );
598
599            // If the SlotRecord already has a revision ID set, this means it already exists
600            // in the database, and should already belong to the latest revision.
601            // However, a slot may already have a revision, but no content ID, if the slot
602            // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
603            // mode, and the respective archive row was not yet migrated to the new schema.
604            // In that case, a new slot row (and content row) must be inserted even during
605            // undeletion.
606            if ( $slot->hasRevision() && $slot->hasContentId() ) {
607                // TODO: properly abort transaction if the assertion fails!
608                Assert::parameter(
609                    $slot->getRevision() === $revisionId,
610                    'slot role ' . $slot->getRole(),
611                    'Existing slot should belong to revision '
612                    . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
613                );
614
615                // Slot exists, nothing to do, move along.
616                // This happens when restoring archived revisions.
617
618                $newSlots[$role] = $slot;
619            } else {
620                $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints );
621            }
622        }
623
624        $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
625
626        $rev = new RevisionStoreRecord(
627            $page,
628            $user,
629            $comment,
630            (object)$revisionRow,
631            new RevisionSlots( $newSlots ),
632            $this->wikiId
633        );
634
635        return $rev;
636    }
637
638    /**
639     * @param IDatabase $dbw
640     * @param int $revisionId
641     * @param SlotRecord $protoSlot
642     * @param PageIdentity $page
643     * @param array $blobHints See the BlobStore::XXX_HINT constants
644     * @return SlotRecord
645     */
646    private function insertSlotOn(
647        IDatabase $dbw,
648        $revisionId,
649        SlotRecord $protoSlot,
650        PageIdentity $page,
651        array $blobHints = []
652    ) {
653        if ( $protoSlot->hasAddress() ) {
654            $blobAddress = $protoSlot->getAddress();
655        } else {
656            $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints );
657        }
658
659        if ( $protoSlot->hasContentId() ) {
660            $contentId = $protoSlot->getContentId();
661        } else {
662            $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
663        }
664
665        $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
666
667        return SlotRecord::newSaved(
668            $revisionId,
669            $contentId,
670            $blobAddress,
671            $protoSlot
672        );
673    }
674
675    /**
676     * Insert IP revision into ip_changes for use when querying for a range.
677     * @param IDatabase $dbw
678     * @param UserIdentity $user
679     * @param RevisionRecord $rev
680     * @param int $revisionId
681     */
682    private function insertIpChangesRow(
683        IDatabase $dbw,
684        UserIdentity $user,
685        RevisionRecord $rev,
686        $revisionId
687    ) {
688        if ( !$user->isRegistered() && IPUtils::isValid( $user->getName() ) ) {
689            $dbw->newInsertQueryBuilder()
690                ->insertInto( 'ip_changes' )
691                ->row( [
692                    'ipc_rev_id'        => $revisionId,
693                    'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
694                    'ipc_hex'           => IPUtils::toHex( $user->getName() ),
695                ] )
696                ->caller( __METHOD__ )->execute();
697
698        }
699    }
700
701    /**
702     * @param IDatabase $dbw
703     * @param RevisionRecord $rev
704     * @param int $parentId
705     *
706     * @return array a revision table row
707     *
708     * @throws MWException
709     * @throws UnknownContentModelException
710     */
711    private function insertRevisionRowOn(
712        IDatabase $dbw,
713        RevisionRecord $rev,
714        $parentId
715    ) {
716        $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId );
717
718        $revisionRow += $this->commentStore->insert(
719            $dbw,
720            'rev_comment',
721            $rev->getComment( RevisionRecord::RAW )
722        );
723
724        $dbw->newInsertQueryBuilder()
725            ->insertInto( 'revision' )
726            ->row( $revisionRow )
727            ->caller( __METHOD__ )->execute();
728
729        if ( !isset( $revisionRow['rev_id'] ) ) {
730            // only if auto-increment was used
731            $revisionRow['rev_id'] = intval( $dbw->insertId() );
732
733            if ( $dbw->getType() === 'mysql' ) {
734                // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
735                // auto-increment value to disk, so on server restart it might reuse IDs from deleted
736                // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
737
738                $maxRevId = intval( $dbw->newSelectQueryBuilder()
739                    ->select( 'MAX(ar_rev_id)' )
740                    ->from( 'archive' )
741                    ->caller( __METHOD__ )
742                    ->fetchField() );
743                $table = 'archive';
744                $maxRevId2 = intval( $dbw->newSelectQueryBuilder()
745                    ->select( 'MAX(slot_revision_id)' )
746                    ->from( 'slots' )
747                    ->caller( __METHOD__ )
748                    ->fetchField() );
749                if ( $maxRevId2 >= $maxRevId ) {
750                    $maxRevId = $maxRevId2;
751                    $table = 'slots';
752                }
753
754                if ( $maxRevId >= $revisionRow['rev_id'] ) {
755                    $this->logger->debug(
756                        '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
757                            . ' Trying to fix it.',
758                        [
759                            'revid' => $revisionRow['rev_id'],
760                            'table' => $table,
761                            'maxrevid' => $maxRevId,
762                        ]
763                    );
764
765                    if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
766                        throw new MWException( 'Failed to get database lock for T202032' );
767                    }
768                    $fname = __METHOD__;
769                    $dbw->onTransactionResolution(
770                        static fn () => $dbw->unlock( 'fix-for-T202032', $fname ),
771                        __METHOD__
772                    );
773
774                    $dbw->newDeleteQueryBuilder()
775                        ->deleteFrom( 'revision' )
776                        ->where( [ 'rev_id' => $revisionRow['rev_id'] ] )
777                        ->caller( __METHOD__ )->execute();
778
779                    // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
780                    // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
781                    // inserts too, though, at least on MariaDB 10.1.29.
782                    //
783                    // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
784                    // transactions in this code path thanks to the row lock from the original ->insert() above.
785                    //
786                    // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
787                    // that's for non-MySQL DBs.
788                    $row1 = $dbw->query(
789                        $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE',
790                        __METHOD__
791                    )->fetchObject();
792
793                    $row2 = $dbw->query(
794                        $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
795                            . ' FOR UPDATE',
796                        __METHOD__
797                    )->fetchObject();
798
799                    $maxRevId = max(
800                        $maxRevId,
801                        $row1 ? intval( $row1->v ) : 0,
802                        $row2 ? intval( $row2->v ) : 0
803                    );
804
805                    // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
806                    // transactions will throw a duplicate key error here. It doesn't seem worth trying
807                    // to avoid that.
808                    $revisionRow['rev_id'] = $maxRevId + 1;
809                    $dbw->newInsertQueryBuilder()
810                        ->insertInto( 'revision' )
811                        ->row( $revisionRow )
812                        ->caller( __METHOD__ )->execute();
813                }
814            }
815        }
816
817        return $revisionRow;
818    }
819
820    /**
821     * @param IDatabase $dbw
822     * @param RevisionRecord $rev
823     * @param int $parentId
824     *
825     * @return array a revision table row
826     */
827    private function getBaseRevisionRow(
828        IDatabase $dbw,
829        RevisionRecord $rev,
830        $parentId
831    ) {
832        // Record the edit in revisions
833        $revisionRow = [
834            'rev_page'       => $rev->getPageId( $this->wikiId ),
835            'rev_parent_id'  => $parentId,
836            'rev_actor'      => $this->actorStore->acquireActorId(
837                $rev->getUser( RevisionRecord::RAW ),
838                $dbw
839            ),
840            'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
841            'rev_timestamp'  => $dbw->timestamp( $rev->getTimestamp() ),
842            'rev_deleted'    => $rev->getVisibility(),
843            'rev_len'        => $rev->getSize(),
844        ];
845
846        if ( $rev->getId( $this->wikiId ) !== null ) {
847            // Needed to restore revisions with their original ID
848            $revisionRow['rev_id'] = $rev->getId( $this->wikiId );
849        }
850
851        return $revisionRow;
852    }
853
854    /**
855     * @param SlotRecord $slot
856     * @param PageIdentity $page
857     * @param array $blobHints See the BlobStore::XXX_HINT constants
858     *
859     * @throws MWException
860     * @return string the blob address
861     */
862    private function storeContentBlob(
863        SlotRecord $slot,
864        PageIdentity $page,
865        array $blobHints = []
866    ) {
867        $content = $slot->getContent();
868        $format = $content->getDefaultFormat();
869        $model = $content->getModel();
870
871        $this->checkContent( $content, $page, $slot->getRole() );
872
873        return $this->blobStore->storeBlob(
874            $content->serialize( $format ),
875            // These hints "leak" some information from the higher abstraction layer to
876            // low level storage to allow for optimization.
877            array_merge(
878                $blobHints,
879                [
880                    BlobStore::DESIGNATION_HINT => 'page-content',
881                    BlobStore::ROLE_HINT => $slot->getRole(),
882                    BlobStore::SHA1_HINT => $slot->getSha1(),
883                    BlobStore::MODEL_HINT => $model,
884                    BlobStore::FORMAT_HINT => $format,
885                ]
886            )
887        );
888    }
889
890    /**
891     * @param SlotRecord $slot
892     * @param IDatabase $dbw
893     * @param int $revisionId
894     * @param int $contentId
895     */
896    private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
897        $dbw->newInsertQueryBuilder()
898            ->insertInto( 'slots' )
899            ->row( [
900                'slot_revision_id' => $revisionId,
901                'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
902                'slot_content_id' => $contentId,
903                // If the slot has a specific origin use that ID, otherwise use the ID of the revision
904                // that we just inserted.
905                'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
906            ] )
907            ->caller( __METHOD__ )->execute();
908    }
909
910    /**
911     * @param SlotRecord $slot
912     * @param IDatabase $dbw
913     * @param string $blobAddress
914     * @return int content row ID
915     */
916    private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
917        $dbw->newInsertQueryBuilder()
918            ->insertInto( 'content' )
919            ->row( [
920                'content_size' => $slot->getSize(),
921                'content_sha1' => $slot->getSha1(),
922                'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
923                'content_address' => $blobAddress,
924            ] )
925            ->caller( __METHOD__ )->execute();
926        return intval( $dbw->insertId() );
927    }
928
929    /**
930     * MCR migration note: this corresponded to Revision::checkContentModel
931     *
932     * @param Content $content
933     * @param PageIdentity $page
934     * @param string $role
935     *
936     * @throws MWException
937     * @throws UnknownContentModelException
938     */
939    private function checkContent( Content $content, PageIdentity $page, string $role ) {
940        // Note: may return null for revisions that have not yet been inserted
941
942        $model = $content->getModel();
943        $format = $content->getDefaultFormat();
944        $handler = $content->getContentHandler();
945
946        if ( !$handler->isSupportedFormat( $format ) ) {
947            throw new MWException(
948                "Can't use format $format with content model $model on $page role $role"
949            );
950        }
951
952        if ( !$content->isValid() ) {
953            throw new MWException(
954                "New content for $page role $role is not valid! Content model is $model"
955            );
956        }
957    }
958
959    /**
960     * Create a new dummy revision for insertion into a page's
961     * history. This will not re-save the text, but simply refer
962     * to the text from the previous version.
963     *
964     * Such revisions can for instance identify page rename
965     * operations and other such meta-modifications.
966     *
967     * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
968     * to prevent a new revision from being inserted before the dummy revision has been written
969     * to the database.
970     *
971     * MCR migration note: this replaced Revision::newNullRevision
972     *
973     * @deprecated since 1.44, use PageUpdater::saveDummyRevision() instead.
974     *
975     * @param IDatabase $dbw used for obtaining the lock on the page table row
976     * @param PageIdentity $page the page to read from
977     * @param CommentStoreComment $comment RevisionRecord's summary
978     * @param bool $minor Whether the revision should be considered as minor
979     * @param UserIdentity $user The user to attribute the revision to
980     *
981     * @return RevisionRecord|null RevisionRecord or null on error
982     */
983    public function newNullRevision(
984        IDatabase $dbw,
985        PageIdentity $page,
986        CommentStoreComment $comment,
987        $minor,
988        UserIdentity $user
989    ) {
990        $this->checkDatabaseDomain( $dbw );
991
992        $pageId = $this->getArticleId( $page );
993
994        // T51581: Lock the page table row to ensure no other process
995        // is adding a revision to the page at the same time.
996        // Avoid locking extra tables, compare T191892.
997        $pageLatest = $dbw->newSelectQueryBuilder()
998            ->select( 'page_latest' )
999            ->forUpdate()
1000            ->from( 'page' )
1001            ->where( [ 'page_id' => $pageId ] )
1002            ->caller( __METHOD__ )->fetchField();
1003
1004        if ( !$pageLatest ) {
1005            $msg = 'T235589: Failed to select table row during dummy revision creation' .
1006                " Page id '$pageId' does not exist.";
1007            $this->logger->error(
1008                $msg,
1009                [ 'exception' => new RuntimeException( $msg ) ]
1010            );
1011
1012            return null;
1013        }
1014
1015        // Fetch the actual revision row from primary DB, without locking all extra tables.
1016        $oldRevision = $this->loadRevisionFromConds(
1017            $dbw,
1018            [ 'rev_id' => intval( $pageLatest ) ],
1019            IDBAccessObject::READ_LATEST,
1020            $page
1021        );
1022
1023        if ( !$oldRevision ) {
1024            $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1025            $this->logger->error(
1026                $msg,
1027                [ 'exception' => new RuntimeException( $msg ) ]
1028            );
1029            return null;
1030        }
1031
1032        // Construct the new revision
1033        $timestamp = MWTimestamp::now( TS::MW );
1034        $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1035
1036        $newRevision->setComment( $comment );
1037        $newRevision->setUser( $user );
1038        $newRevision->setTimestamp( $timestamp );
1039        $newRevision->setMinorEdit( $minor );
1040
1041        return $newRevision;
1042    }
1043
1044    /**
1045     * MCR migration note: this replaced Revision::isUnpatrolled
1046     *
1047     * @todo This is overly specific, so move or kill this method.
1048     *
1049     * @param RevisionRecord $rev
1050     *
1051     * @return int Rcid of the unpatrolled row, zero if there isn't one
1052     */
1053    public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1054        $rc = $this->getRecentChange( $rev );
1055        if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1056            return $rc->getAttribute( 'rc_id' );
1057        } else {
1058            return 0;
1059        }
1060    }
1061
1062    /**
1063     * Get the RC object belonging to the latest revision, if there's one
1064     *
1065     * MCR migration note: this replaced Revision::getRecentChange
1066     *
1067     * @todo move this somewhere else?
1068     *
1069     * @param RevisionRecord $rev
1070     * @param int $flags (optional) $flags include:
1071     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1072     *
1073     * @return null|RecentChange
1074     */
1075    public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1076        if ( $this->wikiId !== RevisionRecord::LOCAL ) {
1077            throw new PreconditionException( 'RecentChangeLookup is only available for the local wiki' );
1078        }
1079
1080        $rc = $this->recentChangeLookup->getRecentChangeByConds(
1081            [
1082                'rc_this_oldid' => $rev->getId( $this->wikiId ),
1083                // rc_this_oldid does not have to be unique,
1084                // in particular, it is shared with categorization
1085                // changes. Prefer the original change because callers
1086                // often expect a change for patrolling.
1087                'rc_source' => [ RecentChange::SRC_EDIT, RecentChange::SRC_NEW, RecentChange::SRC_LOG ],
1088            ],
1089            __METHOD__,
1090            ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST
1091        );
1092
1093        // XXX: cache this locally? Glue it to the RevisionRecord?
1094        return $rc;
1095    }
1096
1097    /**
1098     * Loads a Content object based on a slot row.
1099     *
1100     * This method does not call $slot->getContent(), and may be used as a callback
1101     * called by $slot->getContent().
1102     *
1103     * MCR migration note: this roughly corresponded to Revision::getContentInternal
1104     *
1105     * @param SlotRecord $slot The SlotRecord to load content for
1106     * @param string|null $blobData The content blob, in the form indicated by $blobFlags
1107     * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
1108     *        Use null if no processing should happen. That is in contrast to the empty string,
1109     *        which causes the blob to be decoded according to the configured legacy encoding.
1110     * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
1111     * @param int $queryFlags
1112     *
1113     * @throws RevisionAccessException
1114     * @return Content
1115     */
1116    private function loadSlotContent(
1117        SlotRecord $slot,
1118        ?string $blobData = null,
1119        ?string $blobFlags = null,
1120        ?string $blobFormat = null,
1121        int $queryFlags = 0
1122    ) {
1123        if ( $blobData !== null ) {
1124            $blobAddress = $slot->hasAddress() ? $slot->getAddress() : null;
1125
1126            if ( $blobFlags === null ) {
1127                // No blob flags, so use the blob verbatim.
1128                $data = $blobData;
1129            } else {
1130                try {
1131                    $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress );
1132                } catch ( BadBlobException $e ) {
1133                    throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1134                }
1135
1136                if ( $data === false ) {
1137                    throw new RevisionAccessException(
1138                        'Failed to expand blob data using flags {flags} (key: {cache_key})',
1139                        [
1140                            'flags' => $blobFlags,
1141                            'cache_key' => $blobAddress,
1142                        ]
1143                    );
1144                }
1145            }
1146
1147        } else {
1148            $address = $slot->getAddress();
1149            try {
1150                $data = $this->blobStore->getBlob( $address, $queryFlags );
1151            } catch ( BadBlobException $e ) {
1152                throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1153            } catch ( BlobAccessException $e ) {
1154                throw new RevisionAccessException(
1155                    'Failed to load data blob from {address} for revision {revision}. '
1156                        . 'If this problem persists, use the findBadBlobs maintenance script '
1157                        . 'to investigate the issue and mark the bad blobs.',
1158                    [ 'address' => $e->getMessage(), 'revision' => $slot->getRevision() ],
1159                    0,
1160                    $e
1161                );
1162            }
1163        }
1164
1165        $model = $slot->getModel();
1166
1167        // If the content model is not known, don't fail here (T220594, T220793, T228921)
1168        if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) {
1169            $this->logger->warning(
1170                "Undefined content model '$model', falling back to FallbackContent",
1171                [
1172                    'content_address' => $slot->getAddress(),
1173                    'rev_id' => $slot->getRevision(),
1174                    'role_name' => $slot->getRole(),
1175                    'model_name' => $model,
1176                    'exception' => new RuntimeException()
1177                ]
1178            );
1179
1180            return new FallbackContent( $data, $model );
1181        }
1182
1183        return $this->contentHandlerFactory
1184            ->getContentHandler( $model )
1185            ->unserializeContent( $data, $blobFormat );
1186    }
1187
1188    /**
1189     * Load a page revision from a given revision ID number.
1190     * Returns null if no such revision can be found.
1191     *
1192     * MCR migration note: this replaced Revision::newFromId
1193     *
1194     * $flags include:
1195     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1196     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1197     *
1198     * @param int $id
1199     * @param int $flags (optional)
1200     * @param PageIdentity|null $page The page the revision belongs to.
1201     *        Providing the page may improve performance.
1202     *
1203     * @return RevisionRecord|null
1204     */
1205    public function getRevisionById( $id, $flags = 0, ?PageIdentity $page = null ) {
1206        return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page );
1207    }
1208
1209    /**
1210     * Load either the current, or a specified, revision
1211     * that's attached to a given link target. If not attached
1212     * to that link target, will return null.
1213     *
1214     * MCR migration note: this replaced Revision::newFromTitle
1215     *
1216     * $flags include:
1217     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1218     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1219     *
1220     * @param LinkTarget|PageReference $page Calling with LinkTarget is deprecated since 1.36
1221     * @param int $revId (optional)
1222     * @param int $flags Bitfield (optional)
1223     * @return RevisionRecord|null
1224     */
1225    public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) {
1226        $conds = $this->getPageConditions( $page );
1227        if ( !$conds ) {
1228            return null;
1229        }
1230        if ( !( $page instanceof PageIdentity ) ) {
1231            if ( !( $page instanceof PageReference ) ) {
1232                wfDeprecated( __METHOD__ . ' with a LinkTarget', '1.45' );
1233            }
1234            $page = null;
1235        }
1236
1237        if ( $revId ) {
1238            // Use the specified revision ID.
1239            // Note that we use newRevisionFromConds here because we want to retry
1240            // and fall back to primary DB if the page is not found on a replica.
1241            // Since the caller supplied a revision ID, we are pretty sure the revision is
1242            // supposed to exist, so we should try hard to find it.
1243            $conds['rev_id'] = $revId;
1244            return $this->newRevisionFromConds( $conds, $flags, $page );
1245        } else {
1246            // Use a join to get the latest revision.
1247            // Note that we don't use newRevisionFromConds here because we don't want to retry
1248            // and fall back to primary DB. The assumption is that we only want to force the fallback
1249            // if we are quite sure the revision exists because the caller supplied a revision ID.
1250            // If the page isn't found at all on a replica, it probably simply does not exist.
1251            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1252            $conds[] = 'rev_id=page_latest';
1253            return $this->loadRevisionFromConds( $db, $conds, $flags, $page );
1254        }
1255    }
1256
1257    /**
1258     * Load either the current, or a specified, revision
1259     * that's attached to a given page ID.
1260     * Returns null if no such revision can be found.
1261     *
1262     * MCR migration note: this replaced Revision::newFromPageId
1263     *
1264     * $flags include:
1265     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB (since 1.20)
1266     *      IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB
1267     *
1268     * @param int $pageId
1269     * @param int $revId (optional)
1270     * @param int $flags Bitfield (optional)
1271     * @return RevisionRecord|null
1272     */
1273    public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1274        $conds = [ 'page_id' => $pageId ];
1275        if ( $revId ) {
1276            // Use the specified revision ID.
1277            // Note that we use newRevisionFromConds here because we want to retry
1278            // and fall back to primary DB if the page is not found on a replica.
1279            // Since the caller supplied a revision ID, we are pretty sure the revision is
1280            // supposed to exist, so we should try hard to find it.
1281            $conds['rev_id'] = $revId;
1282            return $this->newRevisionFromConds( $conds, $flags );
1283        } else {
1284            // Use a join to get the latest revision.
1285            // Note that we don't use newRevisionFromConds here because we don't want to retry
1286            // and fall back to primary DB. The assumption is that we only want to force the fallback
1287            // if we are quite sure the revision exists because the caller supplied a revision ID.
1288            // If the page isn't found at all on a replica, it probably simply does not exist.
1289            $db = $this->getDBConnectionRefForQueryFlags( $flags );
1290
1291            $conds[] = 'rev_id=page_latest';
1292
1293            return $this->loadRevisionFromConds( $db, $conds, $flags );
1294        }
1295    }
1296
1297    /**
1298     * Load the revision for the given title with the given timestamp.
1299     * WARNING: Timestamps may in some circumstances not be unique,
1300     * so this isn't the best key to use.
1301     *
1302     * MCR migration note: this replaced Revision::loadFromTimestamp
1303     *
1304     * @param LinkTarget|PageReference $page Calling with LinkTarget is deprecated since 1.36
1305     * @param string $timestamp
1306     * @param int $flags Bitfield (optional) include:
1307     *      IDBAccessObject::READ_LATEST: Select the data from the primary DB
1308     *      IDBAccessObject::READ_LOCKING: Select & lock the data from the primary DB
1309     *      Default: IDBAccessObject::READ_NORMAL
1310     * @return RevisionRecord|null
1311     */
1312    public function getRevisionByTimestamp(
1313        $page,
1314        string $timestamp,
1315        int $flags = IDBAccessObject::READ_NORMAL
1316    ): ?RevisionRecord {
1317        $conds = $this->getPageConditions( $page );
1318        if ( !$conds ) {
1319            return null;
1320        }
1321        if ( !( $page instanceof PageIdentity ) ) {
1322            if ( !( $page instanceof PageReference ) ) {
1323                wfDeprecated( __METHOD__ . ' with a LinkTarget', '1.45' );
1324            }
1325            $page = null;
1326        }
1327
1328        $db = $this->getDBConnectionRefForQueryFlags( $flags );
1329        $conds['rev_timestamp'] = $db->timestamp( $timestamp );
1330        return $this->newRevisionFromConds( $conds, $flags, $page );
1331    }
1332
1333    /**
1334     * @param int $revId The revision to load slots for.
1335     * @param int $queryFlags
1336     * @param PageIdentity $page
1337     *
1338     * @return SlotRecord[]
1339     */
1340    private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) {
1341        // TODO: Find a way to add NS_MODULE from Scribunto here
1342        if ( $page->getNamespace() !== NS_TEMPLATE ) {
1343            $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1344            return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1345        }
1346
1347        // TODO: These caches should not be needed. See T297147#7563670
1348        $res = $this->localCache->getWithSetCallback(
1349            $this->localCache->makeKey(
1350                'revision-slots',
1351                $page->getWikiId(),
1352                $page->getId( $page->getWikiId() ),
1353                $revId
1354            ),
1355            $this->localCache::TTL_HOUR,
1356            function () use ( $revId, $queryFlags, $page ) {
1357                return $this->cache->getWithSetCallback(
1358                    $this->cache->makeKey(
1359                        'revision-slots',
1360                        $page->getWikiId(),
1361                        $page->getId( $page->getWikiId() ),
1362                        $revId
1363                    ),
1364                    WANObjectCache::TTL_DAY,
1365                    function () use ( $revId, $queryFlags, $page ) {
1366                        $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1367                        if ( !$res ) {
1368                            // Avoid caching
1369                            return false;
1370                        }
1371                        return $res;
1372                    }
1373                );
1374            }
1375        );
1376        if ( !$res ) {
1377            $res = [];
1378        }
1379
1380        return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1381    }
1382
1383    private function loadSlotRecordsFromDb( int $revId, int $queryFlags, PageIdentity $page ): array {
1384        $revQuery = $this->getSlotsQueryInfo( [ 'content' ] );
1385
1386        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1387        $res = $db->newSelectQueryBuilder()
1388            ->queryInfo( $revQuery )
1389            ->where( [ 'slot_revision_id' => $revId ] )
1390            ->recency( $queryFlags )
1391            ->caller( __METHOD__ )->fetchResultSet();
1392
1393        if ( !$res->numRows() && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1394            // If we found no slots, try looking on the primary database (T212428, T252156)
1395            $this->logger->info(
1396                __METHOD__ . ' falling back to READ_LATEST.',
1397                [
1398                    'revid' => $revId,
1399                    'exception' => new RuntimeException(),
1400                ]
1401            );
1402            return $this->loadSlotRecordsFromDb(
1403                $revId,
1404                $queryFlags | IDBAccessObject::READ_LATEST,
1405                $page
1406            );
1407        }
1408        return iterator_to_array( $res );
1409    }
1410
1411    /**
1412     * Factory method for SlotRecords based on known slot rows.
1413     *
1414     * @param int $revId The revision to load slots for.
1415     * @param \stdClass[]|IResultWrapper $slotRows
1416     * @param int $queryFlags
1417     * @param PageIdentity $page
1418     * @param array|null $slotContents a map from blobAddress to slot
1419     *     content blob or Content object.
1420     *
1421     * @return SlotRecord[]
1422     */
1423    private function constructSlotRecords(
1424        $revId,
1425        $slotRows,
1426        $queryFlags,
1427        PageIdentity $page,
1428        $slotContents = null
1429    ) {
1430        $slots = [];
1431
1432        foreach ( $slotRows as $row ) {
1433            // Resolve role names and model names from in-memory cache, if they were not joined in.
1434            if ( !isset( $row->role_name ) ) {
1435                $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1436            }
1437
1438            if ( !isset( $row->model_name ) ) {
1439                if ( isset( $row->content_model ) ) {
1440                    $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1441                } else {
1442                    // We may get here if $row->model_name is set but null, perhaps because it
1443                    // came from rev_content_model, which is NULL for the default model.
1444                    $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1445                    $row->model_name = $slotRoleHandler->getDefaultModel( $page );
1446                }
1447            }
1448
1449            // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1450            if ( isset( $row->blob_data ) ) {
1451                $slotContents[$row->content_address] = $row->blob_data;
1452            }
1453
1454            $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1455                $blob = null;
1456                if ( isset( $slotContents[$slot->getAddress()] ) ) {
1457                    $blob = $slotContents[$slot->getAddress()];
1458                    if ( $blob instanceof Content ) {
1459                        return $blob;
1460                    }
1461                }
1462                return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1463            };
1464
1465            $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1466        }
1467
1468        if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1469            $this->logger->error(
1470                __METHOD__ . ': Main slot of revision not found in database. See T212428.',
1471                [
1472                    'revid' => $revId,
1473                    'queryFlags' => $queryFlags,
1474                    'exception' => new RuntimeException(),
1475                ]
1476            );
1477
1478            throw new RevisionAccessException(
1479                'Main slot of revision not found in database. See T212428.'
1480            );
1481        }
1482
1483        return $slots;
1484    }
1485
1486    /**
1487     * Factory method for RevisionSlots based on a revision ID.
1488     *
1489     * @note If other code has a need to construct RevisionSlots objects, this should be made
1490     * public, since RevisionSlots instances should not be constructed directly.
1491     *
1492     * @param int $revId
1493     * @param \stdClass[]|null $slotRows
1494     * @param int $queryFlags
1495     * @param PageIdentity $page
1496     *
1497     * @return RevisionSlots
1498     */
1499    private function newRevisionSlots(
1500        $revId,
1501        $slotRows,
1502        $queryFlags,
1503        PageIdentity $page
1504    ) {
1505        if ( $slotRows ) {
1506            $slots = new RevisionSlots(
1507                $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page )
1508            );
1509        } else {
1510            $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) {
1511                return $this->loadSlotRecords( $revId, $queryFlags, $page );
1512            } );
1513        }
1514
1515        return $slots;
1516    }
1517
1518    /**
1519     * Make a fake RevisionRecord object from an archive table row. This is queried
1520     * for permissions or even inserted (as in Special:Undelete)
1521     *
1522     * The user ID and user name may optionally be supplied using the aliases
1523     * ar_user and ar_user_text (the names of fields which existed before
1524     * MW 1.34).
1525     *
1526     * MCR migration note: this replaced Revision::newFromArchiveRow
1527     *
1528     * @param \stdClass $row
1529     * @param int $queryFlags
1530     * @param PageIdentity|null $page
1531     * @param array $overrides associative array with fields of $row to override. This may be
1532     *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
1533     *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
1534     *   override ar_parent_id.
1535     *
1536     * @return RevisionRecord
1537     */
1538    public function newRevisionFromArchiveRow(
1539        $row,
1540        $queryFlags = 0,
1541        ?PageIdentity $page = null,
1542        array $overrides = []
1543    ) {
1544        return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides );
1545    }
1546
1547    /**
1548     * @see RevisionFactory::newRevisionFromRow
1549     *
1550     * MCR migration note: this replaced Revision::newFromRow
1551     *
1552     * @param \stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1553     * @param int $queryFlags
1554     * @param PageIdentity|null $page Preloaded page object
1555     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1556     *   data is returned from getters, by querying the database as needed
1557     * @return RevisionRecord
1558     */
1559    public function newRevisionFromRow(
1560        $row,
1561        $queryFlags = 0,
1562        ?PageIdentity $page = null,
1563        $fromCache = false
1564    ) {
1565        return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache );
1566    }
1567
1568    /**
1569     * @see newRevisionFromArchiveRow()
1570     * @since 1.35
1571     *
1572     * @param stdClass $row
1573     * @param null|stdClass[]|RevisionSlots $slots
1574     *  - Database rows generated from a query based on getSlotsQueryInfo
1575     *    with the 'content' flag set. Or
1576     *  - RevisionSlots instance
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 newRevisionFromArchiveRowAndSlots(
1587        stdClass $row,
1588        $slots,
1589        int $queryFlags = 0,
1590        ?PageIdentity $page = null,
1591        array $overrides = []
1592    ) {
1593        if ( !$page && isset( $overrides['title'] ) ) {
1594            if ( !( $overrides['title'] instanceof PageIdentity ) ) {
1595                throw new InvalidArgumentException( 'title field override must contain a PageIdentity object.' );
1596            }
1597
1598            $page = $overrides['title'];
1599        }
1600
1601        if ( $page === null ) {
1602            if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1603                // Represent a non-existing page.
1604                // NOTE: The page title may be invalid by latest rules (T384628).
1605                $page = PageIdentityValue::localIdentity( 0, $row->ar_namespace, $row->ar_title );
1606            } else {
1607                throw new InvalidArgumentException(
1608                    'A Title or ar_namespace and ar_title must be given'
1609                );
1610            }
1611        }
1612
1613        foreach ( $overrides as $key => $value ) {
1614            $field = "ar_$key";
1615            $row->$field = $value;
1616        }
1617
1618        try {
1619            $user = $this->actorStore->newActorFromRowFields(
1620                $row->ar_user ?? null,
1621                $row->ar_user_text ?? null,
1622                $row->ar_actor ?? null
1623            );
1624        } catch ( InvalidArgumentException $ex ) {
1625            $this->logger->warning( 'Could not load user for archive revision {rev_id}', [
1626                'ar_rev_id' => $row->ar_rev_id,
1627                'ar_actor' => $row->ar_actor ?? 'null',
1628                'ar_user_text' => $row->ar_user_text ?? 'null',
1629                'ar_user' => $row->ar_user ?? 'null',
1630                'exception' => $ex
1631            ] );
1632            $user = $this->actorStore->getUnknownActor();
1633        }
1634
1635        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1636        // Legacy because $row may have come from self::selectFields()
1637        $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1638
1639        if ( !( $slots instanceof RevisionSlots ) ) {
1640            $slots = $this->newRevisionSlots( (int)$row->ar_rev_id, $slots, $queryFlags, $page );
1641        }
1642        return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId );
1643    }
1644
1645    /**
1646     * @see newFromRevisionRow()
1647     *
1648     * @param stdClass $row A database row generated from a query based on RevisionSelectQueryBuilder
1649     * @param null|stdClass[]|RevisionSlots $slots
1650     *  - Database rows generated from a query based on getSlotsQueryInfo
1651     *    with the 'content' flag set. Or
1652     *  - RevisionSlots instance
1653     * @param int $queryFlags
1654     * @param PageIdentity|null $page
1655     * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
1656     *   data is returned from getters, by querying the database as needed
1657     *
1658     * @return RevisionRecord
1659     * @throws RevisionAccessException
1660     * @see RevisionFactory::newRevisionFromRow
1661     */
1662    public function newRevisionFromRowAndSlots(
1663        stdClass $row,
1664        $slots,
1665        int $queryFlags = 0,
1666        ?PageIdentity $page = null,
1667        bool $fromCache = false
1668    ) {
1669        if ( !$page ) {
1670            if ( isset( $row->page_id )
1671                && isset( $row->page_namespace )
1672                && isset( $row->page_title )
1673            ) {
1674                $page = new PageIdentityValue(
1675                    (int)$row->page_id,
1676                    (int)$row->page_namespace,
1677                    $row->page_title,
1678                    $this->wikiId
1679                );
1680
1681                $page = $this->wrapPage( $page );
1682            } else {
1683                $pageId = (int)( $row->rev_page ?? 0 );
1684                $revId = (int)( $row->rev_id ?? 0 );
1685
1686                $page = $this->getPage( $pageId, $revId, $queryFlags );
1687            }
1688        } else {
1689            $page = $this->ensureRevisionRowMatchesPage( $row, $page );
1690        }
1691
1692        if ( !$page ) {
1693            // This should already have been caught about, but apparently
1694            // it not always is, see T286877.
1695            throw new RevisionAccessException(
1696                "Failed to determine page associated with revision {$row->rev_id}"
1697            );
1698        }
1699
1700        try {
1701            $user = $this->actorStore->newActorFromRowFields(
1702                $row->rev_user ?? null,
1703                $row->rev_user_text ?? null,
1704                $row->rev_actor ?? null
1705            );
1706        } catch ( InvalidArgumentException $ex ) {
1707            $this->logger->warning( 'Could not load user for revision {rev_id}', [
1708                'rev_id' => $row->rev_id,
1709                'rev_actor' => $row->rev_actor ?? 'null',
1710                'rev_user_text' => $row->rev_user_text ?? 'null',
1711                'rev_user' => $row->rev_user ?? 'null',
1712                'exception' => $ex
1713            ] );
1714            $user = $this->actorStore->getUnknownActor();
1715        }
1716
1717        $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1718        // Legacy because $row may have come from self::selectFields()
1719        $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1720
1721        if ( !( $slots instanceof RevisionSlots ) ) {
1722            $slots = $this->newRevisionSlots( (int)$row->rev_id, $slots, $queryFlags, $page );
1723        }
1724
1725        // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data.
1726        if ( $fromCache ) {
1727            $rev = new RevisionStoreCacheRecord(
1728                function ( $revId ) use ( $queryFlags ) {
1729                    $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1730                    $row = $this->fetchRevisionRowFromConds(
1731                        $db,
1732                        [ 'rev_id' => intval( $revId ) ]
1733                    );
1734                    if ( !$row && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
1735                        // If we found no slots, try looking on the primary database (T259738)
1736                        $this->logger->info(
1737                            'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.',
1738                            [
1739                                'revid' => $revId,
1740                                'exception' => new RuntimeException(),
1741                            ]
1742                        );
1743                        $dbw = $this->getDBConnectionRefForQueryFlags( IDBAccessObject::READ_LATEST );
1744                        $row = $this->fetchRevisionRowFromConds(
1745                            $dbw,
1746                            [ 'rev_id' => intval( $revId ) ]
1747                        );
1748                    }
1749                    if ( !$row ) {
1750                        return [ null, null ];
1751                    }
1752                    return [
1753                        $row->rev_deleted,
1754                        $this->actorStore->newActorFromRowFields(
1755                            $row->rev_user ?? null,
1756                            $row->rev_user_text ?? null,
1757                            $row->rev_actor ?? null
1758                        )
1759                    ];
1760                },
1761                $page, $user, $comment, $row, $slots, $this->wikiId
1762            );
1763        } else {
1764            $rev = new RevisionStoreRecord(
1765                $page, $user, $comment, $row, $slots, $this->wikiId );
1766        }
1767        return $rev;
1768    }
1769
1770    /**
1771     * Check that the given row matches the given Title object.
1772     * When a mismatch is detected, this tries to re-load the title from primary DB,
1773     * to avoid spurious errors during page moves.
1774     *
1775     * @param \stdClass $row
1776     * @param PageIdentity $page
1777     * @param array $context
1778     *
1779     * @return Pageidentity
1780     */
1781    private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) {
1782        $revId = (int)( $row->rev_id ?? 0 );
1783        $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id?
1784        $expectedPageId = $page->getId( $this->wikiId );
1785        // Avoid fatal error when the Title's ID changed, T246720
1786        if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) {
1787            // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here.
1788            $pageRec = $this->pageStore->getPageByName(
1789                $page->getNamespace(),
1790                $page->getDBkey(),
1791                IDBAccessObject::READ_LATEST
1792            );
1793            $masterPageId = $pageRec->getId( $this->wikiId );
1794            $masterLatest = $pageRec->getLatest( $this->wikiId );
1795            if ( $revPageId === $masterPageId ) {
1796                if ( $page instanceof Title ) {
1797                    // If we were using a Title object, keep using it, but update the page ID.
1798                    // This way, we don't unexpectedly mix Titles with immutable value objects.
1799                    $page->resetArticleID( $masterPageId );
1800
1801                } else {
1802                    $page = $pageRec;
1803                }
1804
1805                $this->logger->info(
1806                    "Encountered stale Title object",
1807                    [
1808                        'page_id_stale' => $expectedPageId,
1809                        'page_id_reloaded' => $masterPageId,
1810                        'page_latest' => $masterLatest,
1811                        'rev_id' => $revId,
1812                        'exception' => new RuntimeException(),
1813                    ] + $context
1814                );
1815            } else {
1816                $expectedTitle = (string)$page;
1817                if ( $page instanceof Title ) {
1818                    // If we started with a Title, keep using a Title.
1819                    $page = $this->titleFactory->newFromID( $revPageId );
1820                } else {
1821                    $page = $pageRec;
1822                }
1823
1824                // This could happen if a caller to e.g. getRevisionById supplied a Title that is
1825                // plain wrong. In this case, we should ideally throw an IllegalArgumentException.
1826                // However, it is more likely that we encountered a race condition during a page
1827                // move (T268910, T279832) or database corruption (T263340). That situation
1828                // should not be ignored, but we can allow the request to continue in a reasonable
1829                // manner without breaking things for the user.
1830                $this->logger->error(
1831                    "Encountered mismatching Title object (see T259022, T268910, T279832, T263340)",
1832                    [
1833                        'expected_page_id' => $masterPageId,
1834                        'expected_page_title' => $expectedTitle,
1835                        'rev_page' => $revPageId,
1836                        'rev_page_title' => (string)$page,
1837                        'page_latest' => $masterLatest,
1838                        'rev_id' => $revId,
1839                        'exception' => new RuntimeException(),
1840                    ] + $context
1841                );
1842            }
1843        }
1844
1845        // @phan-suppress-next-line PhanTypeMismatchReturnNullable getPageByName/newFromID should not return null
1846        return $page;
1847    }
1848
1849    /**
1850     * Construct a RevisionRecord instance for each row in $rows,
1851     * and return them as an associative array indexed by revision ID.
1852     * Use RevisionSelectQueryBuilder or getArchiveQueryInfo() to construct the
1853     * query that produces the rows.
1854     *
1855     * @param IResultWrapper|\stdClass[] $rows the rows to construct revision records from
1856     * @param array $options Supports the following options:
1857     *               'slots' - whether metadata about revision slots should be
1858     *               loaded immediately. Supports falsy or truthy value as well
1859     *               as an explicit list of slot role names. The main slot will
1860     *               always be loaded.
1861     *               'content' - whether the actual content of the slots should be
1862     *               preloaded.
1863     *               'archive' - whether the rows where generated using getArchiveQueryInfo(),
1864     *                           rather than getQueryInfo.
1865     * @param int $queryFlags
1866     * @param PageIdentity|null $page The page to which all the revision rows belong, if there
1867     *        is such a page and the caller has it handy, so we don't have to look it up again.
1868     *        If this parameter is given and any of the rows has a rev_page_id that is different
1869     *        from Article Id associated with the page, an InvalidArgumentException is thrown.
1870     *
1871     * @return StatusValue<array<int,?RevisionRecord>> a status with an array of successfully fetched revisions
1872     *                     (keyed by revision IDs) and with warnings for the revisions failed to fetch.
1873     */
1874    public function newRevisionsFromBatch(
1875        $rows,
1876        array $options = [],
1877        $queryFlags = 0,
1878        ?PageIdentity $page = null
1879    ) {
1880        $result = new StatusValue();
1881        $archiveMode = $options['archive'] ?? false;
1882
1883        if ( $archiveMode ) {
1884            $revIdField = 'ar_rev_id';
1885        } else {
1886            $revIdField = 'rev_id';
1887        }
1888
1889        $rowsByRevId = [];
1890        $pageIdsToFetchTitles = [];
1891        $titlesByPageKey = [];
1892        foreach ( $rows as $row ) {
1893            if ( isset( $rowsByRevId[$row->$revIdField] ) ) {
1894                $result->warning(
1895                    'internalerror_info',
1896                    "Duplicate rows in newRevisionsFromBatch, $revIdField {$row->$revIdField}"
1897                );
1898            }
1899
1900            // Attach a page key to the row, so we can find and reuse Title objects easily.
1901            $row->_page_key =
1902                $archiveMode ? $row->ar_namespace . ':' . $row->ar_title : $row->rev_page;
1903
1904            if ( $page ) {
1905                if ( !$archiveMode && $row->rev_page != $this->getArticleId( $page ) ) {
1906                    throw new InvalidArgumentException(
1907                        "Revision {$row->$revIdField} doesn't belong to page "
1908                            . $this->getArticleId( $page )
1909                    );
1910                }
1911
1912                if ( $archiveMode
1913                    && ( $row->ar_namespace != $page->getNamespace()
1914                        || $row->ar_title !== $page->getDBkey() )
1915                ) {
1916                    throw new InvalidArgumentException(
1917                        "Revision {$row->$revIdField} doesn't belong to page "
1918                            . $page
1919                    );
1920                }
1921            } elseif ( !isset( $titlesByPageKey[ $row->_page_key ] ) ) {
1922                if ( isset( $row->page_namespace ) && isset( $row->page_title )
1923                    // This should always be true, but just in case we don't have a page_id
1924                    // set or it doesn't match rev_page, let's fetch the title again.
1925                    && isset( $row->page_id ) && isset( $row->rev_page )
1926                    && $row->rev_page === $row->page_id
1927                ) {
1928                    $titlesByPageKey[ $row->_page_key ] = Title::newFromRow( $row );
1929                } elseif ( $archiveMode ) {
1930                    // Can't look up deleted pages by ID, but we have namespace and title
1931                    $titlesByPageKey[ $row->_page_key ] =
1932                        Title::makeTitle( $row->ar_namespace, $row->ar_title );
1933                } else {
1934                    $pageIdsToFetchTitles[] = $row->rev_page;
1935                }
1936            }
1937            $rowsByRevId[$row->$revIdField] = $row;
1938        }
1939
1940        if ( !$rowsByRevId ) {
1941            $result->setResult( true, [] );
1942            return $result;
1943        }
1944
1945        // If the page is not supplied, batch-fetch Title objects.
1946        if ( $page ) {
1947            // same logic as for $row->_page_key above
1948            $pageKey = $archiveMode
1949                ? $page->getNamespace() . ':' . $page->getDBkey()
1950                : $this->getArticleId( $page );
1951
1952            $titlesByPageKey[$pageKey] = $page;
1953        } elseif ( $pageIdsToFetchTitles ) {
1954            // Note: when we fetch titles by ID, the page key is also the ID.
1955            // We should never get here if $archiveMode is true.
1956            Assert::invariant( !$archiveMode, 'Titles are not loaded by ID in archive mode.' );
1957
1958            $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles );
1959            $pageRecords = $this->pageStore
1960                ->newSelectQueryBuilder()
1961                ->wherePageIds( $pageIdsToFetchTitles )
1962                ->caller( __METHOD__ )
1963                ->fetchPageRecordArray();
1964            // Cannot array_merge because it re-indexes entries
1965            $titlesByPageKey = $pageRecords + $titlesByPageKey;
1966        }
1967
1968        // which method to use for creating RevisionRecords
1969        $newRevisionRecord = $archiveMode
1970            ? $this->newRevisionFromArchiveRowAndSlots( ... )
1971            : $this->newRevisionFromRowAndSlots( ... );
1972
1973        if ( !isset( $options['slots'] ) ) {
1974            $result->setResult(
1975                true,
1976                array_map(
1977                    static function ( $row )
1978                    use ( $queryFlags, $titlesByPageKey, $result, $newRevisionRecord, $revIdField ) {
1979                        try {
1980                            if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
1981                                $result->warning(
1982                                    'internalerror_info',
1983                                    "Couldn't find title for rev {$row->$revIdField} "
1984                                    . "(page key {$row->_page_key})"
1985                                );
1986                                return null;
1987                            }
1988                            return $newRevisionRecord( $row, null, $queryFlags,
1989                                $titlesByPageKey[ $row->_page_key ] );
1990                        } catch ( MWException $e ) {
1991                            $result->warning( 'internalerror_info', $e->getMessage() );
1992                            return null;
1993                        }
1994                    },
1995                    $rowsByRevId
1996                )
1997            );
1998            return $result;
1999        }
2000
2001        $slotRowOptions = [
2002            'slots' => $options['slots'] ?? true,
2003            'blobs' => $options['content'] ?? false,
2004        ];
2005
2006        if ( is_array( $slotRowOptions['slots'] )
2007            && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] )
2008        ) {
2009            // Make sure the main slot is always loaded, RevisionRecord requires this.
2010            $slotRowOptions['slots'][] = SlotRecord::MAIN;
2011        }
2012
2013        $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags );
2014
2015        $result->merge( $slotRowsStatus );
2016        $slotRowsByRevId = $slotRowsStatus->getValue();
2017
2018        $result->setResult(
2019            true,
2020            array_map(
2021                function ( $row )
2022                use ( $slotRowsByRevId, $queryFlags, $titlesByPageKey, $result,
2023                    $revIdField, $newRevisionRecord
2024                ) {
2025                    if ( !isset( $slotRowsByRevId[$row->$revIdField] ) ) {
2026                        $result->warning(
2027                            'internalerror_info',
2028                            "Couldn't find slots for rev {$row->$revIdField}"
2029                        );
2030                        return null;
2031                    }
2032                    if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
2033                        $result->warning(
2034                            'internalerror_info',
2035                            "Couldn't find title for rev {$row->$revIdField} "
2036                                . "(page key {$row->_page_key})"
2037                        );
2038                        return null;
2039                    }
2040                    try {
2041                        return $newRevisionRecord(
2042                            $row,
2043                            new RevisionSlots(
2044                                $this->constructSlotRecords(
2045                                    $row->$revIdField,
2046                                    $slotRowsByRevId[$row->$revIdField],
2047                                    $queryFlags,
2048                                    $titlesByPageKey[$row->_page_key]
2049                                )
2050                            ),
2051                            $queryFlags,
2052                            $titlesByPageKey[$row->_page_key]
2053                        );
2054                    } catch ( MWException | ParameterAssertionException $e ) {
2055                        $result->warning( 'internalerror_info', $e->getMessage() );
2056                        return null;
2057                    }
2058                },
2059                $rowsByRevId