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