Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.20% covered (warning)
70.20%
735 / 1047
38.14% covered (danger)
38.14%
37 / 97
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiPage
70.20% covered (warning)
70.20%
735 / 1047
38.14% covered (danger)
38.14%
37 / 97
2923.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 __clone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convertSelectType
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 getPageUpdaterFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBLoadBalancer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActionOverrides
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentHandler
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 clearCacheFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 clearPreparedEdit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 pageData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 pageDataFromTitle
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 pageDataFromId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadPageData
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
9.65
 wasLoadedFrom
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 loadFromRow
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 getId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 exists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasViewableContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRedirect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isNew
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getContentModel
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
3.29
 checkTouched
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getTouched
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getLanguage
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getLinksTimestamp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getLatest
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 loadLastEdit
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 setLastEdit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionRecord
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTimestamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getCreator
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUserText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getComment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getMinorEdit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isCountable
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
 getRedirectTarget
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertRedirectEntry
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 followRedirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectURL
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getContributors
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 shouldCheckParserCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
 getParserOutput
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 doViewUpdates
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 doPurge
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 insertOn
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 updateRevisionOn
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
8
 hasDifferencesOutsideMainSlot
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 supportsSections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceSectionContent
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
27.67
 replaceSectionAtRev
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
9.88
 checkFlags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getDerivedDataUpdater
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 newPageUpdater
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 doUserEditContent
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
11
 makeParserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 makeParserOptionsFromTitleAndModel
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 prepareContentForEdit
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
4.41
 doEditUpdates
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 updateParserCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 doSecondaryDataUpdates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 doUpdateRestrictions
91.93% covered (success)
91.93%
148 / 161
0.00% covered (danger)
0.00%
0 / 1
37.72
 getCurrentUpdate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 insertNullProtectionRevision
84.85% covered (warning)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
5.09
 formatExpiry
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 protectDescription
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 protectDescriptionLog
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 isBatchedDelete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doDeleteArticleReal
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 lockAndGetLatest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 onArticleCreate
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 onArticleDelete
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
4.43
 onArticleEdit
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 queueBacklinksJobs
36.00% covered (danger)
36.00%
9 / 25
0.00% covered (danger)
0.00%
0 / 1
24.78
 purgeInterwikiCheckKey
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
3.46
 getCategories
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getHiddenCategories
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getAutoDeleteReason
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 updateCategoryCounts
87.93% covered (warning)
87.93%
51 / 58
0.00% covered (danger)
0.00%
0 / 1
12.25
 triggerOpportunisticLinksUpdate
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 isLocal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWikiDisplayName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __wakeup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBkey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikiId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canExist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSamePageAs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 toPageRecord
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\Category\Category;
22use MediaWiki\CommentStore\CommentStoreComment;
23use MediaWiki\Content\Content;
24use MediaWiki\Content\ContentHandler;
25use MediaWiki\Context\IContextSource;
26use MediaWiki\DAO\WikiAwareEntityTrait;
27use MediaWiki\Deferred\DeferredUpdates;
28use MediaWiki\Edit\PreparedEdit;
29use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
30use MediaWiki\Linker\LinkTarget;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\MainConfigNames;
33use MediaWiki\MediaWikiServices;
34use MediaWiki\Page\DeletePage;
35use MediaWiki\Page\ExistingPageRecord;
36use MediaWiki\Page\PageIdentity;
37use MediaWiki\Page\PageRecord;
38use MediaWiki\Page\PageReference;
39use MediaWiki\Page\PageStoreRecord;
40use MediaWiki\Page\ParserOutputAccess;
41use MediaWiki\Parser\ParserOptions;
42use MediaWiki\Parser\ParserOutput;
43use MediaWiki\Permissions\Authority;
44use MediaWiki\Revision\RevisionRecord;
45use MediaWiki\Revision\RevisionStore;
46use MediaWiki\Revision\SlotRecord;
47use MediaWiki\Status\Status;
48use MediaWiki\Storage\DerivedPageDataUpdater;
49use MediaWiki\Storage\EditResult;
50use MediaWiki\Storage\PageUpdater;
51use MediaWiki\Storage\PageUpdaterFactory;
52use MediaWiki\Storage\PageUpdateStatus;
53use MediaWiki\Storage\PreparedUpdate;
54use MediaWiki\Storage\RevisionSlotsUpdate;
55use MediaWiki\Title\Title;
56use MediaWiki\Title\TitleArrayFromResult;
57use MediaWiki\User\User;
58use MediaWiki\User\UserArrayFromResult;
59use MediaWiki\User\UserIdentity;
60use MediaWiki\Utils\MWTimestamp;
61use MediaWiki\WikiMap\WikiMap;
62use Wikimedia\Assert\Assert;
63use Wikimedia\Assert\PreconditionException;
64use Wikimedia\NonSerializable\NonSerializableTrait;
65use Wikimedia\Rdbms\FakeResultWrapper;
66use Wikimedia\Rdbms\IDatabase;
67use Wikimedia\Rdbms\IDBAccessObject;
68use Wikimedia\Rdbms\ILoadBalancer;
69use Wikimedia\Rdbms\IReadableDatabase;
70use Wikimedia\Rdbms\RawSQLValue;
71use Wikimedia\Rdbms\SelectQueryBuilder;
72
73/**
74 * @defgroup Page Page
75 */
76
77/**
78 * Base representation for an editable wiki page.
79 *
80 * Some fields are public only for backwards-compatibility. Use accessor methods.
81 * In the past, this class was part of Article.php and everything was public.
82 *
83 * @ingroup Page
84 */
85class WikiPage implements Stringable, Page, PageRecord {
86    use NonSerializableTrait;
87    use ProtectedHookAccessorTrait;
88    use WikiAwareEntityTrait;
89
90    // Constants for $mDataLoadedFrom and related
91
92    /**
93     * @var Title
94     * @note for access by subclasses only
95     */
96    protected $mTitle;
97
98    /**
99     * @var bool
100     * @note for access by subclasses only
101     */
102    protected $mDataLoaded = false;
103
104    /**
105     * A cache of the page_is_redirect field, loaded with page data
106     * @var bool
107     */
108    private $mPageIsRedirectField = false;
109
110    /**
111     * @var bool
112     */
113    private $mIsNew = false;
114
115    /**
116     * @var int|false False means "not loaded"
117     * @note for access by subclasses only
118     */
119    protected $mLatest = false;
120
121    /**
122     * @var PreparedEdit|false Map of cache fields (text, parser output, ect) for a proposed/new edit
123     * @note for access by subclasses only
124     */
125    protected $mPreparedEdit = false;
126
127    /**
128     * @var int|null
129     */
130    protected $mId = null;
131
132    /**
133     * @var int One of the READ_* constants
134     */
135    protected $mDataLoadedFrom = IDBAccessObject::READ_NONE;
136
137    /**
138     * @var RevisionRecord|null
139     */
140    private $mLastRevision = null;
141
142    /**
143     * @var string Timestamp of the current revision or empty string if not loaded
144     */
145    protected $mTimestamp = '';
146
147    /**
148     * @var string
149     */
150    protected $mTouched = '19700101000000';
151
152    /**
153     * @var string|null
154     */
155    protected $mLanguage = null;
156
157    /**
158     * @var string
159     */
160    protected $mLinksUpdated = '19700101000000';
161
162    /**
163     * @var DerivedPageDataUpdater|null
164     */
165    private $derivedDataUpdater = null;
166
167    /**
168     * @param PageIdentity $pageIdentity
169     */
170    public function __construct( PageIdentity $pageIdentity ) {
171        $pageIdentity->assertWiki( PageIdentity::LOCAL );
172
173        // TODO: remove the need for casting to Title.
174        $title = Title::newFromPageIdentity( $pageIdentity );
175        if ( !$title->canExist() ) {
176            throw new InvalidArgumentException( "WikiPage constructed on a Title that cannot exist as a page: $title" );
177        }
178
179        $this->mTitle = $title;
180    }
181
182    /**
183     * Makes sure that the mTitle object is cloned
184     * to the newly cloned WikiPage.
185     */
186    public function __clone() {
187        $this->mTitle = clone $this->mTitle;
188    }
189
190    /**
191     * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants.
192     *
193     * @param stdClass|string|int $type
194     * @return mixed
195     */
196    public static function convertSelectType( $type ) {
197        switch ( $type ) {
198            case 'fromdb':
199                return IDBAccessObject::READ_NORMAL;
200            case 'fromdbmaster':
201                return IDBAccessObject::READ_LATEST;
202            case 'forupdate':
203                return IDBAccessObject::READ_LOCKING;
204            default:
205                // It may already be an integer or whatever else
206                return $type;
207        }
208    }
209
210    /**
211     * @return PageUpdaterFactory
212     */
213    private function getPageUpdaterFactory(): PageUpdaterFactory {
214        return MediaWikiServices::getInstance()->getPageUpdaterFactory();
215    }
216
217    /**
218     * @return RevisionStore
219     */
220    private function getRevisionStore() {
221        return MediaWikiServices::getInstance()->getRevisionStore();
222    }
223
224    /**
225     * @return ILoadBalancer
226     */
227    private function getDBLoadBalancer() {
228        return MediaWikiServices::getInstance()->getDBLoadBalancer();
229    }
230
231    /**
232     * @todo Move this UI stuff somewhere else
233     *
234     * @see ContentHandler::getActionOverrides
235     * @return array
236     */
237    public function getActionOverrides() {
238        return $this->getContentHandler()->getActionOverrides();
239    }
240
241    /**
242     * Returns the ContentHandler instance to be used to deal with the content of this WikiPage.
243     *
244     * Shorthand for ContentHandler::getForModelID( $this->getContentModel() );
245     *
246     * @return ContentHandler
247     *
248     * @since 1.21
249     */
250    public function getContentHandler() {
251        $factory = MediaWikiServices::getInstance()->getContentHandlerFactory();
252        return $factory->getContentHandler( $this->getContentModel() );
253    }
254
255    /**
256     * Get the title object of the article
257     * @return Title Title object of this page
258     */
259    public function getTitle(): Title {
260        return $this->mTitle;
261    }
262
263    /**
264     * Clear the object
265     * @return void
266     */
267    public function clear() {
268        $this->mDataLoaded = false;
269        $this->mDataLoadedFrom = IDBAccessObject::READ_NONE;
270
271        $this->clearCacheFields();
272    }
273
274    /**
275     * Clear the object cache fields
276     * @return void
277     */
278    protected function clearCacheFields() {
279        $this->mId = null;
280        $this->mPageIsRedirectField = false;
281        $this->mLastRevision = null; // Latest revision
282        $this->mTouched = '19700101000000';
283        $this->mLanguage = null;
284        $this->mLinksUpdated = '19700101000000';
285        $this->mTimestamp = '';
286        $this->mIsNew = false;
287        $this->mLatest = false;
288        // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already
289        // checks the requested rev ID and content against the cached one. For most
290        // content types, the output should not change during the lifetime of this cache.
291        // Clearing it can cause extra parses on edit for no reason.
292    }
293
294    /**
295     * Clear the mPreparedEdit cache field, as may be needed by mutable content types
296     * @return void
297     * @since 1.23
298     */
299    public function clearPreparedEdit() {
300        $this->mPreparedEdit = false;
301    }
302
303    /**
304     * Return the tables, fields, and join conditions to be selected to create
305     * a new page object.
306     * @since 1.31
307     * @return array[] With three keys:
308     *   - tables: (string[]) to include in the `$table` to `IReadableDatabase->select()` or
309     *     `SelectQueryBuilder::tables`
310     *   - fields: (string[]) to include in the `$vars` to `IReadableDatabase->select()` or
311     *     `SelectQueryBuilder::fields`
312     *   - joins: (array) to include in the `$join_conds` to `IReadableDatabase->select()` or
313     *     `SelectQueryBuilder::joinConds`
314     * @phan-return array{tables:string[],fields:string[],joins:array}
315     */
316    public static function getQueryInfo() {
317        $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()->get(
318            MainConfigNames::PageLanguageUseDB );
319
320        $ret = [
321            'tables' => [ 'page' ],
322            'fields' => [
323                'page_id',
324                'page_namespace',
325                'page_title',
326                'page_is_redirect',
327                'page_is_new',
328                'page_random',
329                'page_touched',
330                'page_links_updated',
331                'page_latest',
332                'page_len',
333                'page_content_model',
334            ],
335            'joins' => [],
336        ];
337
338        if ( $pageLanguageUseDB ) {
339            $ret['fields'][] = 'page_lang';
340        }
341
342        return $ret;
343    }
344
345    /**
346     * Fetch a page record with the given conditions
347     * @param IReadableDatabase $dbr
348     * @param array $conditions
349     * @param array $options
350     * @return stdClass|false Database result resource, or false on failure
351     */
352    protected function pageData( $dbr, $conditions, $options = [] ) {
353        $pageQuery = self::getQueryInfo();
354
355        $this->getHookRunner()->onArticlePageDataBefore(
356            $this, $pageQuery['fields'], $pageQuery['tables'], $pageQuery['joins'] );
357
358        $row = $dbr->newSelectQueryBuilder()
359            ->queryInfo( $pageQuery )
360            ->where( $conditions )
361            ->caller( __METHOD__ )
362            ->options( $options )
363            ->fetchRow();
364
365        $this->getHookRunner()->onArticlePageDataAfter( $this, $row );
366
367        return $row;
368    }
369
370    /**
371     * Fetch a page record matching the Title object's namespace and title
372     * using a sanitized title string
373     *
374     * @param IReadableDatabase $dbr
375     * @param Title $title
376     * @param int $recency
377     * @return stdClass|false Database result resource, or false on failure
378     */
379    public function pageDataFromTitle( $dbr, $title, $recency = IDBAccessObject::READ_NORMAL ) {
380        if ( !$title->canExist() ) {
381            return false;
382        }
383        $options = [];
384        if ( ( $recency & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) {
385            $options[] = 'FOR UPDATE';
386        } elseif ( ( $recency & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
387            $options[] = 'LOCK IN SHARE MODE';
388        }
389
390        return $this->pageData( $dbr, [
391            'page_namespace' => $title->getNamespace(),
392            'page_title' => $title->getDBkey() ], $options );
393    }
394
395    /**
396     * Fetch a page record matching the requested ID
397     *
398     * @param IReadableDatabase $dbr
399     * @param int $id
400     * @param array $options
401     * @return stdClass|false Database result resource, or false on failure
402     */
403    public function pageDataFromId( $dbr, $id, $options = [] ) {
404        return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
405    }
406
407    /**
408     * Load the object from a given source by title
409     *
410     * @param stdClass|string|int $from One of the following:
411     *   - A DB query result object.
412     *   - "fromdb" or IDBAccessObject::READ_NORMAL to get from a replica DB.
413     *   - "fromdbmaster" or IDBAccessObject::READ_LATEST to get from the primary DB.
414     *   - "forupdate"  or IDBAccessObject::READ_LOCKING to get from the primary DB
415     *     using SELECT FOR UPDATE.
416     *
417     * @return void
418     */
419    public function loadPageData( $from = 'fromdb' ) {
420        $from = self::convertSelectType( $from );
421        if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
422            // We already have the data from the correct location, no need to load it twice.
423            return;
424        }
425
426        if ( is_int( $from ) ) {
427            $loadBalancer = $this->getDBLoadBalancer();
428            if ( ( $from & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
429                $index = DB_PRIMARY;
430            } else {
431                $index = DB_REPLICA;
432            }
433            $db = $loadBalancer->getConnection( $index );
434            $data = $this->pageDataFromTitle( $db, $this->mTitle, $from );
435
436            if ( !$data
437                && $index == DB_REPLICA
438                && $loadBalancer->hasReplicaServers()
439                && $loadBalancer->hasOrMadeRecentPrimaryChanges()
440            ) {
441                $from = IDBAccessObject::READ_LATEST;
442                $db = $loadBalancer->getConnection( DB_PRIMARY );
443                $data = $this->pageDataFromTitle( $db, $this->mTitle, $from );
444            }
445        } else {
446            // No idea from where the caller got this data, assume replica DB.
447            $data = $from;
448            $from = IDBAccessObject::READ_NORMAL;
449        }
450
451        $this->loadFromRow( $data, $from );
452    }
453
454    /**
455     * Checks whether the page data was loaded using the given database access mode (or better).
456     *
457     * @since 1.32
458     *
459     * @param string|int $from One of the following:
460     *   - "fromdb" or IDBAccessObject::READ_NORMAL to get from a replica DB.
461     *   - "fromdbmaster" or IDBAccessObject::READ_LATEST to get from the primary DB.
462     *   - "forupdate"  or IDBAccessObject::READ_LOCKING to get from the primary DB
463     *     using SELECT FOR UPDATE.
464     *
465     * @return bool
466     */
467    public function wasLoadedFrom( $from ) {
468        $from = self::convertSelectType( $from );
469
470        if ( !is_int( $from ) ) {
471            // No idea from where the caller got this data, assume replica DB.
472            $from = IDBAccessObject::READ_NORMAL;
473        }
474
475        if ( $from <= $this->mDataLoadedFrom ) {
476            return true;
477        }
478
479        return false;
480    }
481
482    /**
483     * Load the object from a database row
484     *
485     * @since 1.20
486     * @param stdClass|false $data DB row containing fields returned by getQueryInfo() or false
487     * @param string|int $from One of the following:
488     *        - "fromdb" or IDBAccessObject::READ_NORMAL if the data comes from a replica DB
489     *        - "fromdbmaster" or IDBAccessObject::READ_LATEST if the data comes from the primary DB
490     *        - "forupdate"  or IDBAccessObject::READ_LOCKING if the data comes from
491     *          the primary DB using SELECT FOR UPDATE
492     */
493    public function loadFromRow( $data, $from ) {
494        $lc = MediaWikiServices::getInstance()->getLinkCache();
495        $lc->clearLink( $this->mTitle );
496
497        if ( $data ) {
498            $lc->addGoodLinkObjFromRow( $this->mTitle, $data );
499
500            $this->mTitle->loadFromRow( $data );
501            $this->mId = intval( $data->page_id );
502            $this->mTouched = MWTimestamp::convert( TS_MW, $data->page_touched );
503            $this->mLanguage = $data->page_lang ?? null;
504            $this->mLinksUpdated = $data->page_links_updated === null
505                ? null
506                : MWTimestamp::convert( TS_MW, $data->page_links_updated );
507            $this->mPageIsRedirectField = (bool)$data->page_is_redirect;
508            $this->mIsNew = (bool)( $data->page_is_new ?? 0 );
509            $this->mLatest = intval( $data->page_latest );
510            // T39225: $latest may no longer match the cached latest RevisionRecord object.
511            // Double-check the ID of any cached latest RevisionRecord object for consistency.
512            if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) {
513                $this->mLastRevision = null;
514                $this->mTimestamp = '';
515            }
516        } else {
517            $lc->addBadLinkObj( $this->mTitle );
518
519            $this->mTitle->loadFromRow( false );
520
521            $this->clearCacheFields();
522
523            $this->mId = 0;
524        }
525
526        $this->mDataLoaded = true;
527        $this->mDataLoadedFrom = self::convertSelectType( $from );
528    }
529
530    /**
531     * @param string|false $wikiId
532     *
533     * @return int Page ID
534     */
535    public function getId( $wikiId = self::LOCAL ): int {
536        $this->assertWiki( $wikiId );
537
538        if ( !$this->mDataLoaded ) {
539            $this->loadPageData();
540        }
541        return $this->mId;
542    }
543
544    /**
545     * @return bool Whether or not the page exists in the database
546     */
547    public function exists(): bool {
548        if ( !$this->mDataLoaded ) {
549            $this->loadPageData();
550        }
551        return $this->mId > 0;
552    }
553
554    /**
555     * Check if this page is something we're going to be showing
556     * some sort of sensible content for. If we return false, page
557     * views (plain action=view) will return an HTTP 404 response,
558     * so spiders and robots can know they're following a bad link.
559     *
560     * @return bool
561     */
562    public function hasViewableContent() {
563        return $this->mTitle->isKnown();
564    }
565
566    /**
567     * Is the page a redirect, according to secondary tracking tables?
568     * If this is true, getRedirectTarget() will return a Title.
569     *
570     * @return bool
571     */
572    public function isRedirect() {
573        $this->loadPageData();
574        if ( $this->mPageIsRedirectField ) {
575            return MediaWikiServices::getInstance()->getRedirectLookup()
576                    ->getRedirectTarget( $this->getTitle() ) !== null;
577        }
578
579        return false;
580    }
581
582    /**
583     * Tests if the page is new (only has one revision).
584     * May produce false negatives for some old pages.
585     *
586     * @since 1.36
587     *
588     * @return bool
589     */
590    public function isNew() {
591        if ( !$this->mDataLoaded ) {
592            $this->loadPageData();
593        }
594
595        return $this->mIsNew;
596    }
597
598    /**
599     * Returns the page's content model id (see the CONTENT_MODEL_XXX constants).
600     *
601     * Will use the revisions actual content model if the page exists,
602     * and the page's default if the page doesn't exist yet.
603     *
604     * @return string
605     *
606     * @since 1.21
607     */
608    public function getContentModel() {
609        if ( $this->exists() ) {
610            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
611
612            return $cache->getWithSetCallback(
613                $cache->makeKey( 'page-content-model', $this->getLatest() ),
614                $cache::TTL_MONTH,
615                function () {
616                    $rev = $this->getRevisionRecord();
617                    if ( $rev ) {
618                        // Look at the revision's actual content model
619                        $slot = $rev->getSlot(
620                            SlotRecord::MAIN,
621                            RevisionRecord::RAW
622                        );
623                        return $slot->getModel();
624                    } else {
625                        LoggerFactory::getInstance( 'wikipage' )->warning(
626                            'Page exists but has no (visible) revisions!',
627                            [
628                                'page-title' => $this->mTitle->getPrefixedDBkey(),
629                                'page-id' => $this->getId(),
630                            ]
631                        );
632                        return $this->mTitle->getContentModel();
633                    }
634                },
635                [ 'pcTTL' => $cache::TTL_PROC_LONG ]
636            );
637        }
638
639        // use the default model for this page
640        return $this->mTitle->getContentModel();
641    }
642
643    /**
644     * Loads page_touched and returns a value indicating if it should be used
645     * @return bool True if this page exists and is not a redirect
646     */
647    public function checkTouched() {
648        return ( $this->exists() && !$this->isRedirect() );
649    }
650
651    /**
652     * Get the page_touched field
653     * @return string Timestamp in TS_MW format
654     */
655    public function getTouched() {
656        if ( !$this->mDataLoaded ) {
657            $this->loadPageData();
658        }
659        return $this->mTouched;
660    }
661
662    /**
663     * @return ?string language code for the page
664     */
665    public function getLanguage() {
666        if ( !$this->mDataLoaded ) {
667            $this->loadLastEdit();
668        }
669
670        return $this->mLanguage;
671    }
672
673    /**
674     * Get the page_links_updated field
675     * @return string|null Timestamp in TS_MW format
676     */
677    public function getLinksTimestamp() {
678        if ( !$this->mDataLoaded ) {
679            $this->loadPageData();
680        }
681        return $this->mLinksUpdated;
682    }
683
684    /**
685     * Get the page_latest field
686     * @param string|false $wikiId
687     * @return int The rev_id of current revision
688     */
689    public function getLatest( $wikiId = self::LOCAL ) {
690        $this->assertWiki( $wikiId );
691
692        if ( !$this->mDataLoaded ) {
693            $this->loadPageData();
694        }
695        return (int)$this->mLatest;
696    }
697
698    /**
699     * Loads everything except the text
700     * This isn't necessary for all uses, so it's only done if needed.
701     */
702    protected function loadLastEdit() {
703        if ( $this->mLastRevision !== null ) {
704            return; // already loaded
705        }
706
707        $latest = $this->getLatest();
708        if ( !$latest ) {
709            return; // page doesn't exist or is missing page_latest info
710        }
711
712        if ( $this->mDataLoadedFrom == IDBAccessObject::READ_LOCKING ) {
713            // T39225: if session S1 loads the page row FOR UPDATE, the result always
714            // includes the latest changes committed. This is true even within REPEATABLE-READ
715            // transactions, where S1 normally only sees changes committed before the first S1
716            // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
717            // may not find it since a page row UPDATE and revision row INSERT by S2 may have
718            // happened after the first S1 SELECT.
719            // https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html#isolevel_repeatable-read
720            $revision = $this->getRevisionStore()
721                ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LOCKING );
722        } elseif ( $this->mDataLoadedFrom == IDBAccessObject::READ_LATEST ) {
723            // Bug T93976: if page_latest was loaded from the primary DB, fetch the
724            // revision from there as well, as it may not exist yet on a replica DB.
725            // Also, this keeps the queries in the same REPEATABLE-READ snapshot.
726            $revision = $this->getRevisionStore()
727                ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LATEST );
728        } else {
729            $revision = $this->getRevisionStore()->getKnownCurrentRevision( $this->getTitle(), $latest );
730        }
731
732        if ( $revision ) {
733            $this->setLastEdit( $revision );
734        }
735    }
736
737    /**
738     * Set the latest revision
739     * @param RevisionRecord $revRecord
740     */
741    private function setLastEdit( RevisionRecord $revRecord ) {
742        $this->mLastRevision = $revRecord;
743        $this->mLatest = $revRecord->getId();
744        $this->mTimestamp = $revRecord->getTimestamp();
745        $this->mTouched = max( $this->mTouched, $revRecord->getTimestamp() );
746    }
747
748    /**
749     * Get the latest revision
750     * @since 1.32
751     * @return RevisionRecord|null
752     */
753    public function getRevisionRecord() {
754        $this->loadLastEdit();
755        return $this->mLastRevision;
756    }
757
758    /**
759     * Get the content of the current revision. No side-effects...
760     *
761     * @param int $audience One of:
762     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
763     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
764     *   RevisionRecord::RAW              get the text regardless of permissions
765     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
766     *   to the $audience parameter
767     * @return Content|null The content of the current revision
768     *
769     * @since 1.21
770     */
771    public function getContent( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
772        $this->loadLastEdit();
773        if ( $this->mLastRevision ) {
774            return $this->mLastRevision->getContent( SlotRecord::MAIN, $audience, $performer );
775        }
776        return null;
777    }
778
779    /**
780     * @return string MW timestamp of last article revision
781     */
782    public function getTimestamp() {
783        // Check if the field has been filled by WikiPage::setTimestamp()
784        if ( !$this->mTimestamp ) {
785            $this->loadLastEdit();
786        }
787
788        return MWTimestamp::convert( TS_MW, $this->mTimestamp );
789    }
790
791    /**
792     * Set the page timestamp (use only to avoid DB queries)
793     * @param string $ts MW timestamp of last article revision
794     * @return void
795     */
796    public function setTimestamp( $ts ) {
797        $this->mTimestamp = MWTimestamp::convert( TS_MW, $ts );
798    }
799
800    /**
801     * @param int $audience One of:
802     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
803     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
804     *   RevisionRecord::RAW              get the text regardless of permissions
805     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
806     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
807     *   a user no fallback is provided and the RevisionRecord method will throw an error)
808     * @return int User ID for the user that made the last article revision
809     */
810    public function getUser( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
811        $this->loadLastEdit();
812        if ( $this->mLastRevision ) {
813            $revUser = $this->mLastRevision->getUser( $audience, $performer );
814            return $revUser ? $revUser->getId() : 0;
815        } else {
816            return -1;
817        }
818    }
819
820    /**
821     * Get the User object of the user who created the page
822     * @param int $audience One of:
823     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
824     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
825     *   RevisionRecord::RAW              get the text regardless of permissions
826     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
827     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
828     *   a user no fallback is provided and the RevisionRecord method will throw an error)
829     * @return UserIdentity|null
830     */
831    public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
832        $revRecord = $this->getRevisionStore()->getFirstRevision( $this->getTitle() );
833        if ( $revRecord ) {
834            return $revRecord->getUser( $audience, $performer );
835        } else {
836            return null;
837        }
838    }
839
840    /**
841     * @param int $audience One of:
842     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
843     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
844     *   RevisionRecord::RAW              get the text regardless of permissions
845     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
846     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
847     *   a user no fallback is provided and the RevisionRecord method will throw an error)
848     * @return string Username of the user that made the last article revision
849     */
850    public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
851        $this->loadLastEdit();
852        if ( $this->mLastRevision ) {
853            $revUser = $this->mLastRevision->getUser( $audience, $performer );
854            return $revUser ? $revUser->getName() : '';
855        } else {
856            return '';
857        }
858    }
859
860    /**
861     * @param int $audience One of:
862     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
863     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
864     *   RevisionRecord::RAW              get the text regardless of permissions
865     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
866     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
867     *   a user no fallback is provided and the RevisionRecord method will throw an error)
868     * @return string|null Comment stored for the last article revision, or null if the specified
869     *  audience does not have access to the comment.
870     */
871    public function getComment( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
872        $this->loadLastEdit();
873        if ( $this->mLastRevision ) {
874            $revComment = $this->mLastRevision->getComment( $audience, $performer );
875            return $revComment ? $revComment->text : '';
876        } else {
877            return '';
878        }
879    }
880
881    /**
882     * Returns true if last revision was marked as "minor edit"
883     *
884     * @return bool Minor edit indicator for the last article revision.
885     */
886    public function getMinorEdit() {
887        $this->loadLastEdit();
888        if ( $this->mLastRevision ) {
889            return $this->mLastRevision->isMinor();
890        } else {
891            return false;
892        }
893    }
894
895    /**
896     * Determine whether a page would be suitable for being counted as an
897     * article in the site_stats table based on the title & its content
898     *
899     * @param PreparedEdit|PreparedUpdate|false $editInfo (false):
900     *   An object returned by prepareTextForEdit() or getCurrentUpdate() respectively;
901     *   If false is given, the current database state will be used.
902     *
903     * @return bool
904     */
905    public function isCountable( $editInfo = false ) {
906        $mwServices = MediaWikiServices::getInstance();
907        $articleCountMethod = $mwServices->getMainConfig()->get( MainConfigNames::ArticleCountMethod );
908
909        // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable.
910
911        if ( !$this->mTitle->isContentPage() ) {
912            return false;
913        }
914
915        if ( $editInfo instanceof PreparedEdit ) {
916            // NOTE: only the main slot can make a page a redirect
917            $content = $editInfo->pstContent;
918        } elseif ( $editInfo instanceof PreparedUpdate ) {
919            // NOTE: only the main slot can make a page a redirect
920            $content = $editInfo->getRawContent( SlotRecord::MAIN );
921        } else {
922            $content = $this->getContent();
923        }
924
925        if ( !$content || $content->isRedirect() ) {
926            return false;
927        }
928
929        $hasLinks = null;
930
931        if ( $articleCountMethod === 'link' ) {
932            // nasty special case to avoid re-parsing to detect links
933
934            if ( $editInfo ) {
935                $hasLinks = $editInfo->output->hasLinks();
936            } else {
937                // NOTE: keep in sync with RevisionRenderer::getLinkCount
938                // NOTE: keep in sync with DerivedPageDataUpdater::isCountable
939                $dbr = $mwServices->getConnectionProvider()->getReplicaDatabase();
940                $hasLinks = (bool)$dbr->newSelectQueryBuilder()
941                    ->select( '1' )
942                    ->from( 'pagelinks' )
943                    ->where( [ 'pl_from' => $this->getId() ] )
944                    ->caller( __METHOD__ )->fetchField();
945            }
946        }
947
948        // TODO: MCR: determine $hasLinks for each slot, and use that info
949        // with that slot's Content's isCountable method. That requires per-
950        // slot ParserOutput in the ParserCache, or per-slot info in the
951        // pagelinks table.
952        return $content->isCountable( $hasLinks );
953    }
954
955    /**
956     * If this page is a redirect, get its target
957     *
958     * The target will be fetched from the redirect table if possible.
959     *
960     * @deprecated since 1.38 Use RedirectLookup::getRedirectTarget() instead.
961     *
962     * @return Title|null Title object, or null if this page is not a redirect
963     */
964    public function getRedirectTarget() {
965        $target = MediaWikiServices::getInstance()->getRedirectLookup()->getRedirectTarget( $this );
966        return Title::castFromLinkTarget( $target );
967    }
968
969    /**
970     * Insert or update the redirect table entry for this page to indicate it redirects to $rt
971     * @deprecated since 1.43; use {@link RedirectStore::updateRedirectTarget()} instead.
972     * @param LinkTarget $rt Redirect target
973     * @param int|null $oldLatest Prior page_latest for check and set
974     * @return bool Success
975     */
976    public function insertRedirectEntry( LinkTarget $rt, $oldLatest = null ) {
977        return MediaWikiServices::getInstance()->getRedirectStore()
978            ->updateRedirectTarget( $this, $rt );
979    }
980
981    /**
982     * Get the Title object or URL this page redirects to
983     *
984     * @return bool|Title|string False, Title of in-wiki target, or string with URL
985     */
986    public function followRedirect() {
987        return $this->getRedirectURL( $this->getRedirectTarget() );
988    }
989
990    /**
991     * Get the Title object or URL to use for a redirect. We use Title
992     * objects for same-wiki, non-special redirects and URLs for everything
993     * else.
994     * @param Title $rt Redirect target
995     * @return Title|string|false False, Title object of local target, or string with URL
996     */
997    public function getRedirectURL( $rt ) {
998        if ( !$rt ) {
999            return false;
1000        }
1001
1002        if ( $rt->isExternal() ) {
1003            if ( $rt->isLocal() ) {
1004                // Offsite wikis need an HTTP redirect.
1005                // This can be hard to reverse and may produce loops,
1006                // so they may be disabled in the site configuration.
1007                $source = $this->mTitle->getFullURL( 'redirect=no' );
1008                return $rt->getFullURL( [ 'rdfrom' => $source ] );
1009            } else {
1010                // External pages without "local" bit set are not valid
1011                // redirect targets
1012                return false;
1013            }
1014        }
1015
1016        if ( $rt->isSpecialPage() ) {
1017            // Gotta handle redirects to special pages differently:
1018            // Fill the HTTP response "Location" header and ignore the rest of the page we're on.
1019            // Some pages are not valid targets.
1020            if ( $rt->isValidRedirectTarget() ) {
1021                return $rt->getFullURL();
1022            } else {
1023                return false;
1024            }
1025        } elseif ( !$rt->isValidRedirectTarget() ) {
1026            // We somehow got a bad redirect target into the database (T278367)
1027            return false;
1028        }
1029
1030        return $rt;
1031    }
1032
1033    /**
1034     * Get a list of users who have edited this article, not including the user who made
1035     * the most recent revision, which you can get from $article->getUser() if you want it
1036     * @return UserArrayFromResult
1037     */
1038    public function getContributors() {
1039        // @todo: This is expensive; cache this info somewhere.
1040
1041        $services = MediaWikiServices::getInstance();
1042        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
1043        $actorNormalization = $services->getActorNormalization();
1044        $userIdentityLookup = $services->getUserIdentityLookup();
1045
1046        $user = $this->getUser()
1047            ? User::newFromId( $this->getUser() )
1048            : User::newFromName( $this->getUserText(), false );
1049
1050        $res = $dbr->newSelectQueryBuilder()
1051            ->select( [
1052                'user_id' => 'actor_user',
1053                'user_name' => 'actor_name',
1054                'actor_id' => 'MIN(rev_actor)',
1055                'user_real_name' => 'MIN(user_real_name)',
1056                'timestamp' => 'MAX(rev_timestamp)',
1057            ] )
1058            ->from( 'revision' )
1059            ->join( 'actor', null, 'rev_actor = actor_id' )
1060            ->leftJoin( 'user', null, 'actor_user = user_id' )
1061            ->where( [
1062                'rev_page' => $this->getId(),
1063                // The user who made the top revision gets credited as "this page was last edited by
1064                // John, based on contributions by Tom, Dick and Harry", so don't include them twice.
1065                $dbr->expr( 'rev_actor', '!=', $actorNormalization->findActorId( $user, $dbr ) ),
1066                // Username hidden?
1067                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . ' = 0',
1068            ] )
1069            ->groupBy( [ 'actor_user', 'actor_name' ] )
1070            ->orderBy( 'timestamp', SelectQueryBuilder::SORT_DESC )
1071            ->caller( __METHOD__ )
1072            ->fetchResultSet();
1073        return new UserArrayFromResult( $res );
1074    }
1075
1076    /**
1077     * Should the parser cache be used?
1078     *
1079     * @param ParserOptions $parserOptions ParserOptions to check
1080     * @param int $oldId
1081     * @return bool
1082     */
1083    public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
1084        // NOTE: Keep in sync with ParserOutputAccess::shouldUseCache().
1085        // TODO: Once ParserOutputAccess is stable, deprecated this method.
1086        return $this->exists()
1087            && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
1088            && $this->getContentHandler()->isParserCacheSupported();
1089    }
1090
1091    /**
1092     * Get a ParserOutput for the given ParserOptions and revision ID.
1093     *
1094     * The parser cache will be used if possible. Cache misses that result
1095     * in parser runs are debounced with PoolCounter.
1096     *
1097     * XXX merge this with updateParserCache()?
1098     *
1099     * @since 1.19
1100     * @param ParserOptions|null $parserOptions ParserOptions to use for the parse operation
1101     * @param null|int $oldid Revision ID to get the text from, passing null or 0 will
1102     *   get the current revision (default value)
1103     * @param bool $noCache Do not read from or write to caches.
1104     * @return ParserOutput|false ParserOutput or false if the revision was not found or is not public
1105     */
1106    public function getParserOutput(
1107        ?ParserOptions $parserOptions = null, $oldid = null, $noCache = false
1108    ) {
1109        if ( $oldid ) {
1110            $revision = $this->getRevisionStore()->getRevisionByTitle( $this->getTitle(), $oldid );
1111
1112            if ( !$revision ) {
1113                return false;
1114            }
1115        } else {
1116            $revision = $this->getRevisionRecord();
1117        }
1118
1119        if ( !$parserOptions ) {
1120            $parserOptions = ParserOptions::newFromAnon();
1121        }
1122
1123        $options = $noCache ? ParserOutputAccess::OPT_NO_CACHE : 0;
1124
1125        $status = MediaWikiServices::getInstance()->getParserOutputAccess()->getParserOutput(
1126            $this, $parserOptions, $revision, $options
1127        );
1128        return $status->isOK() ? $status->getValue() : false; // convert null to false
1129    }
1130
1131    /**
1132     * Do standard deferred updates after page view (existing or missing page)
1133     * @param Authority $performer The viewing user
1134     * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed
1135     * @param RevisionRecord|null $oldRev The RevisionRecord associated with $oldid.
1136     */
1137    public function doViewUpdates(
1138        Authority $performer,
1139        $oldid = 0,
1140        ?RevisionRecord $oldRev = null
1141    ) {
1142        if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
1143            return;
1144        }
1145
1146        DeferredUpdates::addCallableUpdate(
1147            function () use ( $performer ) {
1148                // In practice, these hook handlers simply debounce into a post-send
1149                // to do their work since none of the use cases for this hook require
1150                // a blocking pre-send callback.
1151                //
1152                // TODO: Move this hook to post-send.
1153                //
1154                // For now, it is unofficially possible for an extension to use
1155                // onPageViewUpdates to try to insert JavaScript via global $wgOut.
1156                // This isn't supported (the hook doesn't pass OutputPage), and
1157                // can't be since OutputPage may be disabled or replaced on some
1158                // pages that we do support page view updates for. We also run
1159                // this hook after HTMLFileCache, which also naturally can't
1160                // support modifying OutputPage. Handlers that modify the page
1161                // may use onBeforePageDisplay instead, which runs behind
1162                // HTMLFileCache and won't run on non-OutputPage responses.
1163                $legacyUser = MediaWikiServices::getInstance()
1164                    ->getUserFactory()
1165                    ->newFromAuthority( $performer );
1166                $this->getHookRunner()->onPageViewUpdates( $this, $legacyUser );
1167            },
1168            DeferredUpdates::PRESEND
1169        );
1170
1171        // Update newtalk and watchlist notification status
1172        MediaWikiServices::getInstance()
1173            ->getWatchlistManager()
1174            ->clearTitleUserNotifications( $performer, $this, $oldid, $oldRev );
1175    }
1176
1177    /**
1178     * Perform the actions of a page purging
1179     * @return bool
1180     * @note In 1.28 (and only 1.28), this took a $flags parameter that
1181     *  controlled how much purging was done.
1182     */
1183    public function doPurge() {
1184        if ( !$this->getHookRunner()->onArticlePurge( $this ) ) {
1185            return false;
1186        }
1187
1188        $this->mTitle->invalidateCache();
1189
1190        // Clear file cache and send purge after above page_touched update was committed
1191        $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater();
1192        $hcu->purgeTitleUrls( $this->mTitle, $hcu::PURGE_PRESEND );
1193
1194        if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI ) {
1195            MediaWikiServices::getInstance()->getMessageCache()
1196                ->updateMessageOverride( $this->mTitle, $this->getContent() );
1197        }
1198        InfoAction::invalidateCache( $this->mTitle, $this->getLatest() );
1199
1200        return true;
1201    }
1202
1203    /**
1204     * Insert a new empty page record for this article.
1205     * This *must* be followed up by creating a revision
1206     * and running $this->updateRevisionOn( ... );
1207     * or else the record will be left in a funky state.
1208     * Best if all done inside a transaction.
1209     *
1210     * @todo Factor out into a PageStore service, to be used by PageUpdater.
1211     *
1212     * @param IDatabase $dbw
1213     * @param int|null $pageId Custom page ID that will be used for the insert statement
1214     *
1215     * @return int|false The newly created page_id key; false if the row was not
1216     *   inserted, e.g. because the title already existed or because the specified
1217     *   page ID is already in use.
1218     */
1219    public function insertOn( $dbw, $pageId = null ) {
1220        $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : [];
1221        $dbw->newInsertQueryBuilder()
1222            ->insertInto( 'page' )
1223            ->ignore()
1224            ->row( [
1225                'page_namespace'    => $this->mTitle->getNamespace(),
1226                'page_title'        => $this->mTitle->getDBkey(),
1227                'page_is_redirect'  => 0, // Will set this shortly...
1228                'page_is_new'       => 1,
1229                'page_random'       => wfRandom(),
1230                'page_touched'      => $dbw->timestamp(),
1231                'page_latest'       => 0, // Fill this in shortly...
1232                'page_len'          => 0, // Fill this in shortly...
1233            ] + $pageIdForInsert )
1234            ->caller( __METHOD__ )->execute();
1235
1236        if ( $dbw->affectedRows() > 0 ) {
1237            $newid = $pageId ? (int)$pageId : $dbw->insertId();
1238            $this->mId = $newid;
1239            $this->mTitle->resetArticleID( $newid );
1240
1241            return $newid;
1242        } else {
1243            return false; // nothing changed
1244        }
1245    }
1246
1247    /**
1248     * Update the page record to point to a newly saved revision.
1249     *
1250     * @todo Factor out into a PageStore service, or move into PageUpdater.
1251     *
1252     * @param IDatabase $dbw
1253     * @param RevisionRecord $revision For ID number, and text used to set
1254     *   length and redirect status fields.
1255     * @param int|null $lastRevision If given, will not overwrite the page field
1256     *   when different from the currently set value.
1257     *   Giving 0 indicates the new page flag should be set on.
1258     * @param bool|null $lastRevIsRedirect If given, will optimize adding and
1259     *   removing rows in redirect table.
1260     * @return bool Success; false if the page row was missing or page_latest changed
1261     */
1262    public function updateRevisionOn(
1263        $dbw,
1264        RevisionRecord $revision,
1265        $lastRevision = null,
1266        $lastRevIsRedirect = null
1267    ) {
1268        // TODO: move into PageUpdater or PageStore
1269        // NOTE: when doing that, make sure cached fields get reset in doUserEditContent,
1270        // and in the compat stub!
1271
1272        $revId = $revision->getId();
1273        Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' );
1274
1275        $content = $revision->getContent( SlotRecord::MAIN );
1276        $len = $content ? $content->getSize() : 0;
1277        $rt = $content ? $content->getRedirectTarget() : null;
1278        $isNew = $lastRevision === 0;
1279        $isRedirect = $rt !== null;
1280
1281        $conditions = [ 'page_id' => $this->getId() ];
1282
1283        if ( $lastRevision !== null ) {
1284            // An extra check against threads stepping on each other
1285            $conditions['page_latest'] = $lastRevision;
1286        }
1287
1288        $model = $revision->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel();
1289
1290        $row = [ /* SET */
1291            'page_latest'        => $revId,
1292            'page_touched'       => $dbw->timestamp( $revision->getTimestamp() ),
1293            'page_is_new'        => $isNew ? 1 : 0,
1294            'page_is_redirect'   => $isRedirect ? 1 : 0,
1295            'page_len'           => $len,
1296            'page_content_model' => $model,
1297        ];
1298
1299        $dbw->newUpdateQueryBuilder()
1300            ->update( 'page' )
1301            ->set( $row )
1302            ->where( $conditions )
1303            ->caller( __METHOD__ )->execute();
1304
1305        $result = $dbw->affectedRows() > 0;
1306        if ( $result ) {
1307            $insertedRow = $this->pageData( $dbw, [ 'page_id' => $this->getId() ] );
1308
1309            if ( !$insertedRow ) {
1310                throw new RuntimeException( 'Failed to load freshly inserted row' );
1311            }
1312
1313            $this->mTitle->loadFromRow( $insertedRow );
1314            MediaWikiServices::getInstance()->getRedirectStore()
1315                ->updateRedirectTarget( $this, $rt, $lastRevIsRedirect );
1316            $this->setLastEdit( $revision );
1317            $this->mPageIsRedirectField = (bool)$rt;
1318            $this->mIsNew = $isNew;
1319
1320            // Update the LinkCache.
1321            $linkCache = MediaWikiServices::getInstance()->getLinkCache();
1322            $linkCache->addGoodLinkObjFromRow(
1323                $this->mTitle,
1324                $insertedRow
1325            );
1326        }
1327
1328        return $result;
1329    }
1330
1331    /**
1332     * Helper method for checking whether two revisions have differences that go
1333     * beyond the main slot.
1334     *
1335     * MCR migration note: this method should go away!
1336     *
1337     * @deprecated since 1.43; Use only as a stop-gap before refactoring to support MCR.
1338     *
1339     * @param RevisionRecord $a
1340     * @param RevisionRecord $b
1341     * @return bool
1342     */
1343    public static function hasDifferencesOutsideMainSlot( RevisionRecord $a, RevisionRecord $b ) {
1344        $aSlots = $a->getSlots();
1345        $bSlots = $b->getSlots();
1346        $changedRoles = $aSlots->getRolesWithDifferentContent( $bSlots );
1347
1348        return ( $changedRoles !== [ SlotRecord::MAIN ] && $changedRoles !== [] );
1349    }
1350
1351    /**
1352     * Returns true if this page's content model supports sections.
1353     *
1354     * @return bool
1355     *
1356     * @todo The skin should check this and not offer section functionality if
1357     *   sections are not supported.
1358     * @todo The EditPage should check this and not offer section functionality
1359     *   if sections are not supported.
1360     */
1361    public function supportsSections() {
1362        return $this->getContentHandler()->supportsSections();
1363    }
1364
1365    /**
1366     * @param string|int|null|false $sectionId Section identifier as a number or string
1367     * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1368     * or 'new' for a new section.
1369     * @param Content $sectionContent New content of the section.
1370     * @param string $sectionTitle New section's subject, only if $section is "new".
1371     * @param string $edittime Revision timestamp or null to use the current revision.
1372     *
1373     * @return Content|null New complete article content, or null if error.
1374     *
1375     * @since 1.21
1376     * @deprecated since 1.24, use replaceSectionAtRev instead
1377     */
1378    public function replaceSectionContent(
1379        $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
1380    ) {
1381        $baseRevId = null;
1382        if ( $edittime && $sectionId !== 'new' ) {
1383            $lb = $this->getDBLoadBalancer();
1384            $rev = $this->getRevisionStore()->getRevisionByTimestamp( $this->mTitle, $edittime );
1385            // Try the primary database if this thread may have just added it.
1386            // The logic to fallback to the primary database if the replica is missing
1387            // the revision could be generalized into RevisionStore, but we don't want
1388            // to encourage loading of revisions by timestamp.
1389            if ( !$rev
1390                && $lb->hasReplicaServers()
1391                && $lb->hasOrMadeRecentPrimaryChanges()
1392            ) {
1393                $rev = $this->getRevisionStore()->getRevisionByTimestamp(
1394                    $this->mTitle, $edittime, IDBAccessObject::READ_LATEST );
1395            }
1396            if ( $rev ) {
1397                $baseRevId = $rev->getId();
1398            }
1399        }
1400
1401        return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
1402    }
1403
1404    /**
1405     * @param string|int|null|false $sectionId Section identifier as a number or string
1406     * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1407     * or 'new' for a new section.
1408     * @param Content $sectionContent New content of the section.
1409     * @param string $sectionTitle New section's subject, only if $section is "new".
1410     * @param int|null $baseRevId
1411     *
1412     * @return Content|null New complete article content, or null if error.
1413     *
1414     * @since 1.24
1415     */
1416    public function replaceSectionAtRev( $sectionId, Content $sectionContent,
1417        $sectionTitle = '', $baseRevId = null
1418    ) {
1419        if ( strval( $sectionId ) === '' ) {
1420            // Whole-page edit; let the whole text through
1421            $newContent = $sectionContent;
1422        } else {
1423            if ( !$this->supportsSections() ) {
1424                throw new BadMethodCallException( "sections not supported for content model " .
1425                    $this->getContentHandler()->getModelID() );
1426            }
1427
1428            // T32711: always use current version when adding a new section
1429            if ( $baseRevId === null || $sectionId === 'new' ) {
1430                $oldContent = $this->getContent();
1431            } else {
1432                $revRecord = $this->getRevisionStore()->getRevisionById( $baseRevId );
1433                if ( !$revRecord ) {
1434                    wfDebug( __METHOD__ . " asked for bogus section (page: " .
1435                        $this->getId() . "; section: $sectionId)" );
1436                    return null;
1437                }
1438
1439                $oldContent = $revRecord->getContent( SlotRecord::MAIN );
1440            }
1441
1442            if ( !$oldContent ) {
1443                wfDebug( __METHOD__ . ": no page text" );
1444                return null;
1445            }
1446
1447            $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
1448        }
1449
1450        return $newContent;
1451    }
1452
1453    /**
1454     * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
1455     *
1456     * @deprecated since 1.32, use exists() instead, or simply omit the EDIT_UPDATE
1457     * and EDIT_NEW flags. To protect against race conditions, use PageUpdater::grabParentRevision.
1458     *
1459     * @param int $flags
1460     * @return int Updated $flags
1461     */
1462    public function checkFlags( $flags ) {
1463        if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
1464            if ( $this->exists() ) {
1465                $flags |= EDIT_UPDATE;
1466            } else {
1467                $flags |= EDIT_NEW;
1468            }
1469        }
1470
1471        return $flags;
1472    }
1473
1474    /**
1475     * Returns a DerivedPageDataUpdater for use with the given target revision or new content.
1476     * This method attempts to re-use the same DerivedPageDataUpdater instance for subsequent calls.
1477     * The parameters passed to this method are used to ensure that the DerivedPageDataUpdater
1478     * returned matches that caller's expectations, allowing an existing instance to be re-used
1479     * if the given parameters match that instance's internal state according to
1480     * DerivedPageDataUpdater::isReusableFor(), and creating a new instance of the parameters do not
1481     * match the existing one.
1482     *
1483     * If neither $forRevision nor $forUpdate is given, a new DerivedPageDataUpdater is always
1484     * created, replacing any DerivedPageDataUpdater currently cached.
1485     *
1486     * MCR migration note: this replaces WikiPage::prepareContentForEdit.
1487     *
1488     * @since 1.32
1489     *
1490     * @param UserIdentity|null $forUser The user that will be used for, or was used for, PST.
1491     * @param RevisionRecord|null $forRevision The revision created by the edit for which
1492     *        to perform updates, if the edit was already saved.
1493     * @param RevisionSlotsUpdate|null $forUpdate The new content to be saved by the edit (pre PST),
1494     *        if the edit was not yet saved.
1495     * @param bool $forEdit Only re-use if the cached DerivedPageDataUpdater has the current
1496     *       revision as the edit's parent revision. This ensures that the same
1497     *       DerivedPageDataUpdater cannot be re-used for two consecutive edits.
1498     *
1499     * @return DerivedPageDataUpdater
1500     */
1501    private function getDerivedDataUpdater(
1502        ?UserIdentity $forUser = null,
1503        ?RevisionRecord $forRevision = null,
1504        ?RevisionSlotsUpdate $forUpdate = null,
1505        $forEdit = false
1506    ) {
1507        if ( !$forRevision && !$forUpdate ) {
1508            // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is
1509            // going to use it with.
1510            $this->derivedDataUpdater = null;
1511        }
1512
1513        if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) {
1514            // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference
1515            // to it did not yet initialize it, because we don't know what data it will be
1516            // initialized with.
1517            $this->derivedDataUpdater = null;
1518        }
1519
1520        // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance.
1521        // However, there is no good way to construct a cache key. We'd need to check against all
1522        // cached instances.
1523
1524        if ( $this->derivedDataUpdater
1525            && !$this->derivedDataUpdater->isReusableFor(
1526                $forUser,
1527                $forRevision,
1528                $forUpdate,
1529                $forEdit ? $this->getLatest() : null
1530            )
1531        ) {
1532            $this->derivedDataUpdater = null;
1533        }
1534
1535        if ( !$this->derivedDataUpdater ) {
1536            $this->derivedDataUpdater =
1537                $this->getPageUpdaterFactory()->newDerivedPageDataUpdater( $this );
1538        }
1539
1540        return $this->derivedDataUpdater;
1541    }
1542
1543    /**
1544     * Returns a PageUpdater for creating new revisions on this page (or creating the page).
1545     *
1546     * The PageUpdater can also be used to detect the need for edit conflict resolution,
1547     * and to protected such conflict resolution from concurrent edits using a check-and-set
1548     * mechanism.
1549     *
1550     * @since 1.32
1551     *
1552     * @note Once extensions no longer rely on WikiPage to get access to the state of an ongoing
1553     * edit via prepareContentForEdit() and WikiPage::getCurrentUpdate(),
1554     * this method should be deprecated and callers should be migrated to using
1555     * PageUpdaterFactory::newPageUpdater() instead.
1556     *
1557     * @param Authority|UserIdentity $performer
1558     * @param RevisionSlotsUpdate|null $forUpdate If given, allows any cached ParserOutput
1559     *        that may already have been returned via getDerivedDataUpdater to be re-used.
1560     *
1561     * @return PageUpdater
1562     */
1563    public function newPageUpdater( $performer, ?RevisionSlotsUpdate $forUpdate = null ) {
1564        if ( $performer instanceof Authority ) {
1565            // TODO: Deprecate this. But better get rid of this method entirely.
1566            $performer = $performer->getUser();
1567        }
1568
1569        $pageUpdater = $this->getPageUpdaterFactory()->newPageUpdaterForDerivedPageDataUpdater(
1570            $this,
1571            $performer,
1572            $this->getDerivedDataUpdater( $performer, null, $forUpdate, true )
1573        );
1574
1575        return $pageUpdater;
1576    }
1577
1578    /**
1579     * Change an existing article or create a new article. Updates RC and all necessary caches,
1580     * optionally via the deferred update array.
1581     *
1582     * @deprecated since 1.36, use PageUpdater::saveRevision instead. Note that the new method
1583     * expects callers to take care of checking EDIT_MINOR against the minoredit right, and to
1584     * apply the autopatrol right as appropriate.
1585     *
1586     * @param Content $content New content
1587     * @param Authority $performer doing the edit
1588     * @param string|CommentStoreComment $summary Edit summary
1589     * @param int $flags Bitfield:
1590     *      EDIT_NEW
1591     *          Article is known or assumed to be non-existent, create a new one
1592     *      EDIT_UPDATE
1593     *          Article is known or assumed to be pre-existing, update it
1594     *      EDIT_MINOR
1595     *          Mark this edit minor, if the user is allowed to do so
1596     *      EDIT_SUPPRESS_RC
1597     *          Do not log the change in recentchanges
1598     *      EDIT_FORCE_BOT
1599     *          Mark the edit a "bot" edit regardless of user rights
1600     *      EDIT_AUTOSUMMARY
1601     *          Fill in blank summaries with generated text where possible
1602     *      EDIT_INTERNAL
1603     *          Signal that the page retrieve/save cycle happened entirely in this request.
1604     *
1605     * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1606     * article will be detected. If EDIT_UPDATE is specified and the article
1607     * doesn't exist, the function will return an edit-gone-missing error. If
1608     * EDIT_NEW is specified and the article does exist, an edit-already-exists
1609     * error will be returned. These two conditions are also possible with
1610     * auto-detection due to MediaWiki's performance-optimised locking strategy.
1611     *
1612     * @param int|false $originalRevId: The ID of an original revision that the edit
1613     * restores or repeats. The new revision is expected to have the exact same content as
1614     * the given original revision. This is used with rollbacks and with dummy "null" revisions
1615     * which are created to record things like page moves. Default is false, meaning we are not
1616     * making a rollback edit.
1617     * @param array|null $tags Change tags to apply to this edit
1618     * Callers are responsible for permission checks
1619     * (with ChangeTags::canAddTagsAccompanyingChange)
1620     * @param int $undidRevId Id of revision that was undone or 0
1621     *
1622     * @return PageUpdateStatus Possible errors:
1623     *     edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1624     *       set the fatal flag of $status.
1625     *     edit-gone-missing: In update mode, but the article didn't exist.
1626     *     edit-conflict: In update mode, the article changed unexpectedly.
1627     *     edit-no-change: Warning that the text was the same as before.
1628     *     edit-already-exists: In creation mode, but the article already exists.
1629     *
1630     *  Extensions may define additional errors.
1631     *
1632     *  $return->value will contain an associative array with members as follows:
1633     *     new: Boolean indicating if the function attempted to create a new article.
1634     *     revision-record: The revision record object for the inserted revision, or null.
1635     *
1636     * @since 1.36
1637     */
1638    public function doUserEditContent(
1639        Content $content,
1640        Authority $performer,
1641        $summary,
1642        $flags = 0,
1643        $originalRevId = false,
1644        $tags = [],
1645        $undidRevId = 0
1646    ): PageUpdateStatus {
1647        $useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get(
1648            MainConfigNames::UseNPPatrol );
1649        $useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get(
1650            MainConfigNames::UseRCPatrol );
1651        if ( !( $summary instanceof CommentStoreComment ) ) {
1652            $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
1653        }
1654
1655        // TODO: this check is here for backwards-compatibility with 1.31 behavior.
1656        // Checking the minoredit right should be done in the same place the 'bot' right is
1657        // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave.
1658        if ( ( $flags & EDIT_MINOR ) && !$performer->isAllowed( 'minoredit' ) ) {
1659            $flags &= ~EDIT_MINOR;
1660        }
1661
1662        $slotsUpdate = new RevisionSlotsUpdate();
1663        $slotsUpdate->modifyContent( SlotRecord::MAIN, $content );
1664
1665        // NOTE: while doUserEditContent() executes, callbacks to getDerivedDataUpdater and
1666        // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also
1667        // used by this PageUpdater. However, there is no guarantee for this.
1668        $updater = $this->newPageUpdater( $performer, $slotsUpdate )
1669                ->setContent( SlotRecord::MAIN, $content )
1670            ->setOriginalRevisionId( $originalRevId );
1671        if ( $undidRevId ) {
1672            $updater->markAsRevert(
1673                EditResult::REVERT_UNDO,
1674                $undidRevId,
1675                $originalRevId ?: null
1676            );
1677        }
1678
1679        $needsPatrol = $useRCPatrol || ( $useNPPatrol && !$this->exists() );
1680
1681        // TODO: this logic should not be in the storage layer, it's here for compatibility
1682        // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
1683        // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
1684
1685        if ( $needsPatrol && $performer->authorizeWrite( 'autopatrol', $this->getTitle() ) ) {
1686            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
1687        }
1688
1689        $updater->addTags( $tags );
1690
1691        $revRec = $updater->saveRevision(
1692            $summary,
1693            $flags
1694        );
1695
1696        // $revRec will be null if the edit failed, or if no new revision was created because
1697        // the content did not change.
1698        if ( $revRec ) {
1699            // update cached fields
1700            // TODO: this is currently redundant to what is done in updateRevisionOn.
1701            // But updateRevisionOn() should move into PageStore, and then this will be needed.
1702            $this->setLastEdit( $revRec );
1703        }
1704
1705        return $updater->getStatus();
1706    }
1707
1708    /**
1709     * Get parser options suitable for rendering the primary article wikitext
1710     *
1711     * @see ParserOptions::newCanonical
1712     *
1713     * @param IContextSource|UserIdentity|string $context One of the following:
1714     *        - IContextSource: Use the User and the Language of the provided
1715     *          context
1716     *        - UserIdentity: Use the provided UserIdentity object and $wgLang
1717     *          for the language, so use an IContextSource object if possible.
1718     *        - 'canonical': Canonical options (anonymous user with default
1719     *          preferences and content language).
1720     * @return ParserOptions
1721     */
1722    public function makeParserOptions( $context ) {
1723        return self::makeParserOptionsFromTitleAndModel(
1724            $this->getTitle(), $this->getContentModel(), $context
1725        );
1726    }
1727
1728    /**
1729     * Create canonical parser options for a given title and content model.
1730     * @internal
1731     * @param PageReference $pageRef
1732     * @param string $contentModel
1733     * @param IContextSource|UserIdentity|string $context See ::makeParserOptions
1734     * @return ParserOptions
1735     */
1736    public static function makeParserOptionsFromTitleAndModel(
1737        PageReference $pageRef, string $contentModel, $context
1738    ) {
1739        $options = ParserOptions::newCanonical( $context );
1740
1741        $title = Title::newFromPageReference( $pageRef );
1742        if ( $title->isConversionTable() ) {
1743            // @todo ConversionTable should become a separate content model, so
1744            // we don't need special cases like this one, but see T313455.
1745            $options->disableContentConversion();
1746        }
1747
1748        return $options;
1749    }
1750
1751    /**
1752     * Prepare content which is about to be saved.
1753     *
1754     * Prior to 1.30, this returned a stdClass.
1755     *
1756     * @deprecated since 1.32, use newPageUpdater() or getCurrentUpdate() instead.
1757     * @note Calling without a UserIdentity was separately deprecated from 1.37 to 1.39, since
1758     * 1.39 the UserIdentity has been required.
1759     *
1760     * @param Content $content
1761     * @param RevisionRecord|null $revision
1762     *        Used with vary-revision or vary-revision-id.
1763     * @param UserIdentity $user
1764     * @param string|null $serialFormat IGNORED
1765     * @param bool $useStash Use prepared edit stash
1766     *
1767     * @return PreparedEdit
1768     *
1769     * @since 1.21
1770     */
1771    public function prepareContentForEdit(
1772        Content $content,
1773        ?RevisionRecord $revision,
1774        UserIdentity $user,
1775        $serialFormat = null,
1776        $useStash = true
1777    ) {
1778        $slots = RevisionSlotsUpdate::newFromContent( [ SlotRecord::MAIN => $content ] );
1779        $updater = $this->getDerivedDataUpdater( $user, $revision, $slots );
1780
1781        if ( !$updater->isUpdatePrepared() ) {
1782            $updater->prepareContent( $user, $slots, $useStash );
1783
1784            if ( $revision ) {
1785                $updater->prepareUpdate(
1786                    $revision,
1787                    [
1788                        'causeAction' => 'prepare-edit',
1789                        'causeAgent' => $user->getName(),
1790                    ]
1791                );
1792            }
1793        }
1794
1795        return $updater->getPreparedEdit();
1796    }
1797
1798    /**
1799     * Do standard deferred updates after page edit.
1800     * Update links tables, site stats, search index and message cache.
1801     * Purges pages that include this page if the text was changed here.
1802     * Every 100th edit, prune the recent changes table.
1803     *
1804     * @deprecated since 1.32 (soft), use DerivedPageDataUpdater::doUpdates instead.
1805     *
1806     * @param RevisionRecord $revisionRecord (Switched from the old Revision class to
1807     *    RevisionRecord since 1.35)
1808     * @param UserIdentity $user User object that did the revision
1809     * @param array $options Array of options, following indexes are used:
1810     * - changed: bool, whether the revision changed the content (default true)
1811     * - created: bool, whether the revision created the page (default false)
1812     * - moved: bool, whether the page was moved (default false)
1813     * - restored: bool, whether the page was undeleted (default false)
1814     * - oldrevision: RevisionRecord object for the pre-update revision (default null)
1815     * - oldcountable: bool, null, or string 'no-change' (default null):
1816     *   - bool: whether the page was counted as an article before that
1817     *     revision, only used in changed is true and created is false
1818     *   - null: if created is false, don't update the article count; if created
1819     *     is true, do update the article count
1820     *   - 'no-change': don't update the article count, ever
1821     *  - causeAction: an arbitrary string identifying the reason for the update.
1822     *    See DataUpdate::getCauseAction(). (default 'edit-page')
1823     *  - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1824     *    (string, defaults to the passed user)
1825     */
1826    public function doEditUpdates(
1827        RevisionRecord $revisionRecord,
1828        UserIdentity $user,
1829        array $options = []
1830    ) {
1831        $options += [
1832            'causeAction' => 'edit-page',
1833            'causeAgent' => $user->getName(),
1834        ];
1835
1836        $updater = $this->getDerivedDataUpdater( $user, $revisionRecord );
1837
1838        $updater->prepareUpdate( $revisionRecord, $options );
1839
1840        $updater->doUpdates();
1841    }
1842
1843    /**
1844     * Update the parser cache.
1845     *
1846     * @note This does not update links tables. Use doSecondaryDataUpdates() for that.
1847     *
1848     * @param array $options
1849     *   - causeAction: an arbitrary string identifying the reason for the update.
1850     *     See DataUpdate::getCauseAction(). (default 'edit-page')
1851     *   - causeAgent: name of the user who caused the update (string, defaults to the
1852     *     user who created the revision)
1853     * @since 1.32
1854     */
1855    public function updateParserCache( array $options = [] ) {
1856        $revision = $this->getRevisionRecord();
1857        if ( !$revision || !$revision->getId() ) {
1858            LoggerFactory::getInstance( 'wikipage' )->info(
1859                __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
1860            );
1861            return;
1862        }
1863        $userIdentity = $revision->getUser( RevisionRecord::RAW );
1864
1865        $updater = $this->getDerivedDataUpdater( $userIdentity, $revision );
1866        $updater->prepareUpdate( $revision, $options );
1867        $updater->doParserCacheUpdate();
1868    }
1869
1870    /**
1871     * Do secondary data updates (such as updating link tables).
1872     * Secondary data updates are only a small part of the updates needed after saving
1873     * a new revision; normally PageUpdater::doUpdates should be used instead (which includes
1874     * secondary data updates). This method is provided for partial purges.
1875     *
1876     * @note This does not update the parser cache. Use updateParserCache() for that.
1877     *
1878     * @param array $options
1879     *   - recursive (bool, default true): whether to do a recursive update (update pages that
1880     *     depend on this page, e.g. transclude it). This will set the $recursive parameter of
1881     *     Content::getSecondaryDataUpdates. Typically this should be true unless the update
1882     *     was something that did not really change the page, such as a null edit.
1883     *   - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1884     *     user who created the revision)
1885     *   - causeAction: an arbitrary string identifying the reason for the update.
1886     *     See DataUpdate::getCauseAction(). (default 'unknown')
1887     *   - causeAgent: name of the user who caused the update (string, default 'unknown')
1888     *   - defer: one of the DeferredUpdates constants, or false to run immediately (default: false).
1889     *     Note that even when this is set to false, some updates might still get deferred (as
1890     *     some update might directly add child updates to DeferredUpdates).
1891     *   - known-revision-output: a combined canonical ParserOutput for the revision, perhaps
1892     *     from some cache. The caller is responsible for ensuring that the ParserOutput indeed
1893     *     matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
1894     *     for the time until caches have been changed to store RenderedRevision states instead
1895     *     of ParserOutput objects. (default: null) (since 1.33)
1896     * @since 1.32
1897     */
1898    public function doSecondaryDataUpdates( array $options = [] ) {
1899        $options['recursive'] ??= true;
1900        $revision = $this->getRevisionRecord();
1901        if ( !$revision || !$revision->getId() ) {
1902            LoggerFactory::getInstance( 'wikipage' )->info(
1903                __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
1904            );
1905            return;
1906        }
1907        $userIdentity = $revision->getUser( RevisionRecord::RAW );
1908
1909        $updater = $this->getDerivedDataUpdater( $userIdentity, $revision );
1910        $updater->prepareUpdate( $revision, $options );
1911        $updater->doSecondaryDataUpdates( $options );
1912    }
1913
1914    /**
1915     * Update the article's restriction field, and leave a log entry.
1916     * This works for protection both existing and non-existing pages.
1917     *
1918     * @param array $limit Set of restriction keys
1919     * @param array $expiry Per restriction type expiration
1920     * @param bool &$cascade Set to false if cascading protection isn't allowed.
1921     * @param string $reason
1922     * @param UserIdentity $user The user updating the restrictions
1923     * @param string[] $tags Change tags to add to the pages and protection log entries
1924     *   ($user should be able to add the specified tags before this is called)
1925     * @return Status Status object; if action is taken, $status->value is the log_id of the
1926     *   protection log entry.
1927     */
1928    public function doUpdateRestrictions( array $limit, array $expiry,
1929        &$cascade, $reason, UserIdentity $user, $tags = []
1930    ) {
1931        $services = MediaWikiServices::getInstance();
1932        $readOnlyMode = $services->getReadOnlyMode();
1933        if ( $readOnlyMode->isReadOnly() ) {
1934            return Status::newFatal( wfMessage( 'readonlytext', $readOnlyMode->getReason() ) );
1935        }
1936
1937        $this->loadPageData( 'fromdbmaster' );
1938        $restrictionStore = $services->getRestrictionStore();
1939        $restrictionStore->loadRestrictions( $this->mTitle, IDBAccessObject::READ_LATEST );
1940        $restrictionTypes = $restrictionStore->listApplicableRestrictionTypes( $this->mTitle );
1941        $id = $this->getId();
1942
1943        if ( !$cascade ) {
1944            $cascade = false;
1945        }
1946
1947        // Take this opportunity to purge out expired restrictions
1948        Title::purgeExpiredRestrictions();
1949
1950        // @todo: Same limitations as described in ProtectionForm.php (line 37);
1951        // we expect a single selection, but the schema allows otherwise.
1952        $isProtected = false;
1953        $protect = false;
1954        $changed = false;
1955
1956        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
1957
1958        foreach ( $restrictionTypes as $action ) {
1959            if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
1960                $expiry[$action] = 'infinity';
1961            }
1962            if ( !isset( $limit[$action] ) ) {
1963                $limit[$action] = '';
1964            } elseif ( $limit[$action] != '' ) {
1965                $protect = true;
1966            }
1967
1968            // Get current restrictions on $action
1969            $current = implode( '', $restrictionStore->getRestrictions( $this->mTitle, $action ) );
1970            if ( $current != '' ) {
1971                $isProtected = true;
1972            }
1973
1974            if ( $limit[$action] != $current ) {
1975                $changed = true;
1976            } elseif ( $limit[$action] != '' ) {
1977                // Only check expiry change if the action is actually being
1978                // protected, since expiry does nothing on an not-protected
1979                // action.
1980                if ( $restrictionStore->getRestrictionExpiry( $this->mTitle, $action ) != $expiry[$action] ) {
1981                    $changed = true;
1982                }
1983            }
1984        }
1985
1986        if ( !$changed && $protect && $restrictionStore->areRestrictionsCascading( $this->mTitle ) != $cascade ) {
1987            $changed = true;
1988        }
1989
1990        // If nothing has changed, do nothing
1991        if ( !$changed ) {
1992            return Status::newGood();
1993        }
1994
1995        if ( !$protect ) { // No protection at all means unprotection
1996            $revCommentMsg = 'unprotectedarticle-comment';
1997            $logAction = 'unprotect';
1998        } elseif ( $isProtected ) {
1999            $revCommentMsg = 'modifiedarticleprotection-comment';
2000            $logAction = 'modify';
2001        } else {
2002            $revCommentMsg = 'protectedarticle-comment';
2003            $logAction = 'protect';
2004        }
2005
2006        $logRelationsValues = [];
2007        $logRelationsField = null;
2008        $logParamsDetails = [];
2009
2010        // Null revision (used for change tag insertion)
2011        $nullRevisionRecord = null;
2012
2013        $legacyUser = $services->getUserFactory()->newFromUserIdentity( $user );
2014        if ( !$this->getHookRunner()->onArticleProtect( $this, $legacyUser, $limit, $reason ) ) {
2015            return Status::newGood();
2016        }
2017
2018        if ( $id ) { // Protection of existing page
2019            // Only certain restrictions can cascade...
2020            $editrestriction = isset( $limit['edit'] )
2021                ? [ $limit['edit'] ]
2022                : $restrictionStore->getRestrictions( $this->mTitle, 'edit' );
2023            foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2024                $editrestriction[$key] = 'editprotected'; // backwards compatibility
2025            }
2026            foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2027                $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2028            }
2029
2030            $cascadingRestrictionLevels = $services->getMainConfig()
2031                ->get( MainConfigNames::CascadingRestrictionLevels );
2032
2033            foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2034                $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2035            }
2036            foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2037                $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2038            }
2039
2040            // The schema allows multiple restrictions
2041            if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2042                $cascade = false;
2043            }
2044
2045            // insert null revision to identify the page protection change as edit summary
2046            $latest = $this->getLatest();
2047            $nullRevisionRecord = $this->insertNullProtectionRevision(
2048                $revCommentMsg,
2049                $limit,
2050                $expiry,
2051                $cascade,
2052                $reason,
2053                $user
2054            );
2055
2056            if ( $nullRevisionRecord === null ) {
2057                return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2058            }
2059
2060            $logRelationsField = 'pr_id';
2061
2062            // T214035: Avoid deadlock on MySQL.
2063            // Do a DELETE by primary key (pr_id) for any existing protection rows.
2064            // On MySQL and derivatives, unconditionally deleting by page ID (pr_page) would.
2065            // place a gap lock if there are no matching rows. This can deadlock when another
2066            // thread modifies protection settings for page IDs in the same gap.
2067            $existingProtectionIds = $dbw->newSelectQueryBuilder()
2068                ->select( 'pr_id' )
2069                ->from( 'page_restrictions' )
2070                ->where( [ 'pr_page' => $id, 'pr_type' => array_map( 'strval', array_keys( $limit ) ) ] )
2071                ->caller( __METHOD__ )->fetchFieldValues();
2072
2073            if ( $existingProtectionIds ) {
2074                $dbw->newDeleteQueryBuilder()
2075                    ->deleteFrom( 'page_restrictions' )
2076                    ->where( [ 'pr_id' => $existingProtectionIds ] )
2077                    ->caller( __METHOD__ )->execute();
2078            }
2079
2080            // Update restrictions table
2081            foreach ( $limit as $action => $restrictions ) {
2082                if ( $restrictions != '' ) {
2083                    $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2084                    $dbw->newInsertQueryBuilder()
2085                        ->insertInto( 'page_restrictions' )
2086                        ->row( [
2087                            'pr_page' => $id,
2088                            'pr_type' => $action,
2089                            'pr_level' => $restrictions,
2090                            'pr_cascade' => $cascadeValue,
2091                            'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2092                        ] )
2093                        ->caller( __METHOD__ )->execute();
2094                    $logRelationsValues[] = $dbw->insertId();
2095                    $logParamsDetails[] = [
2096                        'type' => $action,
2097                        'level' => $restrictions,
2098                        'expiry' => $expiry[$action],
2099                        'cascade' => (bool)$cascadeValue,
2100                    ];
2101                }
2102            }
2103
2104            $this->getHookRunner()->onRevisionFromEditComplete(
2105                $this, $nullRevisionRecord, $latest, $user, $tags );
2106        } else { // Protection of non-existing page (also known as "title protection")
2107            // Cascade protection is meaningless in this case
2108            $cascade = false;
2109
2110            if ( $limit['create'] != '' ) {
2111                $commentFields = $services->getCommentStore()->insert( $dbw, 'pt_reason', $reason );
2112                $dbw->newReplaceQueryBuilder()
2113                    ->table( 'protected_titles' )
2114                    ->uniqueIndexFields( [ 'pt_namespace', 'pt_title' ] )
2115                    ->rows( [
2116                        'pt_namespace' => $this->mTitle->getNamespace(),
2117                        'pt_title' => $this->mTitle->getDBkey(),
2118                        'pt_create_perm' => $limit['create'],
2119                        'pt_timestamp' => $dbw->timestamp(),
2120                        'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2121                        'pt_user' => $user->getId(),
2122                    ] + $commentFields )
2123                    ->caller( __METHOD__ )->execute();
2124                $logParamsDetails[] = [
2125                    'type' => 'create',
2126                    'level' => $limit['create'],
2127                    'expiry' => $expiry['create'],
2128                ];
2129            } else {
2130                $dbw->newDeleteQueryBuilder()
2131                    ->deleteFrom( 'protected_titles' )
2132                    ->where( [
2133                        'pt_namespace' => $this->mTitle->getNamespace(),
2134                        'pt_title' => $this->mTitle->getDBkey()
2135                    ] )
2136                    ->caller( __METHOD__ )->execute();
2137            }
2138        }
2139
2140        $this->getHookRunner()->onArticleProtectComplete( $this, $legacyUser, $limit, $reason );
2141
2142        $services->getRestrictionStore()->flushRestrictions( $this->mTitle );
2143
2144        InfoAction::invalidateCache( $this->mTitle );
2145
2146        if ( $logAction == 'unprotect' ) {
2147            $params = [];
2148        } else {
2149            $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2150            $params = [
2151                '4::description' => $protectDescriptionLog, // parameter for IRC
2152                '5:bool:cascade' => $cascade,
2153                'details' => $logParamsDetails, // parameter for localize and api
2154            ];
2155        }
2156
2157        // Update the protection log
2158        $logEntry = new ManualLogEntry( 'protect', $logAction );
2159        $logEntry->setTarget( $this->mTitle );
2160        $logEntry->setComment( $reason );
2161        $logEntry->setPerformer( $user );
2162        $logEntry->setParameters( $params );
2163        if ( $nullRevisionRecord !== null ) {
2164            $logEntry->setAssociatedRevId( $nullRevisionRecord->getId() );
2165        }
2166        $logEntry->addTags( $tags );
2167        if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2168            $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2169        }
2170        $logId = $logEntry->insert();
2171        $logEntry->publish( $logId );
2172
2173        return Status::newGood( $logId );
2174    }
2175
2176    /**
2177     * Get the state of an ongoing update, shortly before or just after it is saved to the database.
2178     * If there is no ongoing edit tracked by this WikiPage instance, this methods throws a
2179     * PreconditionException.
2180     *
2181     * If possible, state is shared with subsequent calls of getPreparedUpdate(),
2182     * prepareContentForEdit(), and newPageUpdater().
2183     *
2184     * @note This method should generally be avoided, since it forces WikiPage to maintain state
2185     *       representing ongoing edits. Code that initiates an edit should use newPageUpdater()
2186     *       instead. Hooks that interact with the edit should have a the relevant
2187     *       information provided as a PageUpdater, PreparedUpdate, or RenderedRevision.
2188     *
2189     * @throws PreconditionException if there is no ongoing update. This method must only be
2190     *         called after newPageUpdater() had already been called, typically while executing
2191     *         a handler for a hook that is triggered during a page edit.
2192     * @return PreparedUpdate
2193     *
2194     * @since 1.38
2195     */
2196    public function getCurrentUpdate(): PreparedUpdate {
2197        Assert::precondition(
2198            $this->derivedDataUpdater !== null,
2199            'There is no ongoing update tracked by this instance of WikiPage!'
2200        );
2201
2202        return $this->derivedDataUpdater;
2203    }
2204
2205    /**
2206     * Insert a new null revision for this page.
2207     *
2208     * @since 1.35
2209     *
2210     * @param string $revCommentMsg Comment message key for the revision
2211     * @param array $limit Set of restriction keys
2212     * @param array $expiry Per restriction type expiration
2213     * @param bool $cascade Set to false if cascading protection isn't allowed.
2214     * @param string $reason
2215     * @param UserIdentity $user User to attribute to
2216     * @return RevisionRecord|null Null on error
2217     */
2218    public function insertNullProtectionRevision(
2219        string $revCommentMsg,
2220        array $limit,
2221        array $expiry,
2222        bool $cascade,
2223        string $reason,
2224        UserIdentity $user
2225    ): ?RevisionRecord {
2226        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
2227
2228        // Prepare a null revision to be added to the history
2229        $editComment = wfMessage(
2230            $revCommentMsg,
2231            $this->mTitle->getPrefixedText(),
2232            $user->getName()
2233        )->inContentLanguage()->text();
2234        if ( $reason ) {
2235            $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2236        }
2237        $protectDescription = $this->protectDescription( $limit, $expiry );
2238        if ( $protectDescription ) {
2239            $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2240            $editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2241                ->inContentLanguage()->text();
2242        }
2243        if ( $cascade ) {
2244            $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2245            $editComment .= wfMessage( 'brackets' )->params(
2246                wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2247            )->inContentLanguage()->text();
2248        }
2249
2250        $revStore = $this->getRevisionStore();
2251        $comment = CommentStoreComment::newUnsavedComment( $editComment );
2252        $nullRevRecord = $revStore->newNullRevision(
2253            $dbw,
2254            $this->getTitle(),
2255            $comment,
2256            true,
2257            $user
2258        );
2259
2260        if ( $nullRevRecord ) {
2261            $inserted = $revStore->insertRevisionOn( $nullRevRecord, $dbw );
2262
2263            // Update page record and touch page
2264            $oldLatest = $inserted->getParentId();
2265
2266            $this->updateRevisionOn( $dbw, $inserted, $oldLatest );
2267
2268            return $inserted;
2269        } else {
2270            return null;
2271        }
2272    }
2273
2274    /**
2275     * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
2276     * @return string
2277     */
2278    protected function formatExpiry( $expiry ) {
2279        if ( $expiry != 'infinity' ) {
2280            $contLang = MediaWikiServices::getInstance()->getContentLanguage();
2281            return wfMessage(
2282                'protect-expiring',
2283                $contLang->timeanddate( $expiry, false, false ),
2284                $contLang->date( $expiry, false, false ),
2285                $contLang->time( $expiry, false, false )
2286            )->inContentLanguage()->text();
2287        } else {
2288            return wfMessage( 'protect-expiry-indefinite' )
2289                ->inContentLanguage()->text();
2290        }
2291    }
2292
2293    /**
2294     * Builds the description to serve as comment for the edit.
2295     *
2296     * @param array $limit Set of restriction keys
2297     * @param array $expiry Per restriction type expiration
2298     * @return string
2299     */
2300    public function protectDescription( array $limit, array $expiry ) {
2301        $protectDescription = '';
2302
2303        foreach ( array_filter( $limit ) as $action => $restrictions ) {
2304            # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2305            # All possible message keys are listed here for easier grepping:
2306            # * restriction-create
2307            # * restriction-edit
2308            # * restriction-move
2309            # * restriction-upload
2310            $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2311            # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2312            # with '' filtered out. All possible message keys are listed below:
2313            # * protect-level-autoconfirmed
2314            # * protect-level-sysop
2315            $restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2316                ->inContentLanguage()->text();
2317
2318            $expiryText = $this->formatExpiry( $expiry[$action] );
2319
2320            if ( $protectDescription !== '' ) {
2321                $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2322            }
2323            $protectDescription .= wfMessage( 'protect-summary-desc' )
2324                ->params( $actionText, $restrictionsText, $expiryText )
2325                ->inContentLanguage()->text();
2326        }
2327
2328        return $protectDescription;
2329    }
2330
2331    /**
2332     * Builds the description to serve as comment for the log entry.
2333     *
2334     * Some bots may parse IRC lines, which are generated from log entries which contain plain
2335     * protect description text. Keep them in old format to avoid breaking compatibility.
2336     * TODO: Fix protection log to store structured description and format it on-the-fly.
2337     *
2338     * @param array $limit Set of restriction keys
2339     * @param array $expiry Per restriction type expiration
2340     * @return string
2341     */
2342    public function protectDescriptionLog( array $limit, array $expiry ) {
2343        $protectDescriptionLog = '';
2344
2345        $dirMark = MediaWikiServices::getInstance()->getContentLanguage()->getDirMark();
2346        foreach ( array_filter( $limit ) as $action => $restrictions ) {
2347            $expiryText = $this->formatExpiry( $expiry[$action] );
2348            $protectDescriptionLog .=
2349                $dirMark .
2350                "[$action=$restrictions] ($expiryText)";
2351        }
2352
2353        return trim( $protectDescriptionLog );
2354    }
2355
2356    /**
2357     * Determines if deletion of this page would be batched (executed over time by the job queue)
2358     * or not (completed in the same request as the delete call).
2359     *
2360     * It is unlikely but possible that an edit from another request could push the page over the
2361     * batching threshold after this function is called, but before the caller acts upon the
2362     * return value.  Callers must decide for themselves how to deal with this.  $safetyMargin
2363     * is provided as an unreliable but situationally useful help for some common cases.
2364     *
2365     * @deprecated since 1.37 Use DeletePage::isBatchedDelete instead.
2366     *
2367     * @param int $safetyMargin Added to the revision count when checking for batching
2368     * @return bool True if deletion would be batched, false otherwise
2369     */
2370    public function isBatchedDelete( $safetyMargin = 0 ) {
2371        $deleteRevisionsBatchSize = MediaWikiServices::getInstance()
2372            ->getMainConfig()->get( MainConfigNames::DeleteRevisionsBatchSize );
2373
2374        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
2375        $revCount = $this->getRevisionStore()->countRevisionsByPageId( $dbr, $this->getId() );
2376        $revCount += $safetyMargin;
2377
2378        return $revCount >= $deleteRevisionsBatchSize;
2379    }
2380
2381    /**
2382     * Back-end article deletion
2383     * Deletes the article with database consistency, writes logs, purges caches
2384     *
2385     * @since 1.19
2386     * @since 1.35 Signature changed, user moved to second parameter to prepare for requiring
2387     *             a user to be passed
2388     * @since 1.36 User second parameter is required
2389     * @deprecated since 1.37 Use DeletePage instead. Calling ::deleteIfAllowed and letting DeletePage handle
2390     * permission checks is preferred over doing permission checks yourself and then calling ::deleteUnsafe.
2391     * Note that DeletePage returns a good status with false value in case of scheduled deletion, instead of
2392     * a status with a warning. Also, the new method doesn't have an $error parameter, since any error is
2393     * added to the returned Status.
2394     *
2395     * @param string $reason Delete reason for deletion log
2396     * @param UserIdentity $deleter The deleting user
2397     * @param bool $suppress Suppress all revisions and log the deletion in
2398     *   the suppression log instead of the deletion log
2399     * @param bool|null $u1 Unused
2400     * @param array|string &$error Array of errors to append to
2401     * @param mixed $u2 Unused
2402     * @param string[]|null $tags Tags to apply to the deletion action
2403     * @param string $logsubtype
2404     * @param bool $immediate false allows deleting over time via the job queue
2405     * @return Status Status object; if successful, $status->value is the log_id of the
2406     *   deletion log entry. If the page couldn't be deleted because it wasn't
2407     *   found, $status is a non-fatal 'cannotdelete' error
2408     */
2409    public function doDeleteArticleReal(
2410        $reason, UserIdentity $deleter, $suppress = false, $u1 = null, &$error = '', $u2 = null,
2411        $tags = [], $logsubtype = 'delete', $immediate = false
2412    ) {
2413        $services = MediaWikiServices::getInstance();
2414        $deletePage = $services->getDeletePageFactory()->newDeletePage(
2415            $this,
2416            $services->getUserFactory()->newFromUserIdentity( $deleter )
2417        );
2418
2419        $status = $deletePage
2420            ->setSuppress( $suppress )
2421            ->setTags( $tags ?: [] )
2422            ->setLogSubtype( $logsubtype )
2423            ->forceImmediate( $immediate )
2424            ->keepLegacyHookErrorsSeparate()
2425            ->deleteUnsafe( $reason );
2426        $error = $deletePage->getLegacyHookErrors();
2427        if ( $status->isGood() ) {
2428            // BC with old return format
2429            if ( $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) {
2430                $status->warning( 'delete-scheduled', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2431            } else {
2432                $status->value = $deletePage->getSuccessfulDeletionsIDs()[DeletePage::PAGE_BASE];
2433            }
2434        }
2435        return $status;
2436    }
2437
2438    /**
2439     * Lock the page row for this title+id and return page_latest (or 0)
2440     *
2441     * @return int Returns 0 if no row was found with this title+id
2442     * @since 1.27
2443     */
2444    public function lockAndGetLatest() {
2445        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
2446        return (int)$dbw->newSelectQueryBuilder()
2447            ->select( 'page_latest' )
2448            ->forUpdate()
2449            ->from( 'page' )
2450            ->where( [
2451                'page_id' => $this->getId(),
2452                // Typically page_id is enough, but some code might try to do
2453                // updates assuming the title is the same, so verify that
2454                'page_namespace' => $this->getTitle()->getNamespace(),
2455                'page_title' => $this->getTitle()->getDBkey()
2456            ] )
2457            ->caller( __METHOD__ )->fetchField();
2458    }
2459
2460    /**
2461     * The onArticle*() functions are supposed to be a kind of hooks
2462     * which should be called whenever any of the specified actions
2463     * are done.
2464     *
2465     * This is a good place to put code to clear caches, for instance.
2466     *
2467     * This is called on page move and undelete, as well as edit
2468     *
2469     * @param Title $title
2470     * @param bool $maybeIsRedirect True if the page may have been created as a redirect.
2471     *   If false, this is used as a hint to skip some unnecessary updates.
2472     */
2473    public static function onArticleCreate( Title $title, $maybeIsRedirect = true ) {
2474        // TODO: move this into a PageEventEmitter service
2475
2476        // Update existence markers on article/talk tabs...
2477        $other = $title->getOtherPage();
2478
2479        $services = MediaWikiServices::getInstance();
2480        $hcu = $services->getHtmlCacheUpdater();
2481        $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2482
2483        $title->touchLinks();
2484        $title->deleteTitleProtection();
2485
2486        $services->getLinkCache()->invalidateTitle( $title );
2487
2488        DeferredUpdates::addCallableUpdate(
2489            static function () use ( $title, $maybeIsRedirect ) {
2490                self::queueBacklinksJobs( $title, true, $maybeIsRedirect, 'create-page' );
2491            }
2492        );
2493
2494        if ( $title->getNamespace() === NS_CATEGORY ) {
2495            // Load the Category object, which will schedule a job to create
2496            // the category table row if necessary. Checking a replica DB is ok
2497            // here, in the worst case it'll run an unnecessary recount job on
2498            // a category that probably doesn't have many members.
2499            Category::newFromTitle( $title )->getID();
2500        }
2501    }
2502
2503    /**
2504     * Clears caches when article is deleted
2505     *
2506     * @param Title $title
2507     */
2508    public static function onArticleDelete( Title $title ) {
2509        // TODO: move this into a PageEventEmitter service
2510
2511        // Update existence markers on article/talk tabs...
2512        $other = $title->getOtherPage();
2513
2514        $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater();
2515        $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2516
2517        $title->touchLinks();
2518
2519        $services = MediaWikiServices::getInstance();
2520        $services->getLinkCache()->invalidateTitle( $title );
2521
2522        InfoAction::invalidateCache( $title );
2523
2524        // Messages
2525        if ( $title->getNamespace() === NS_MEDIAWIKI ) {
2526            $services->getMessageCache()->updateMessageOverride( $title, null );
2527        }
2528
2529        // Invalidate caches of articles which include this page
2530        DeferredUpdates::addCallableUpdate( static function () use ( $title ) {
2531            self::queueBacklinksJobs( $title, true, true, 'delete-page' );
2532        } );
2533
2534        // User talk pages
2535        if ( $title->getNamespace() === NS_USER_TALK ) {
2536            $user = User::newFromName( $title->getText(), false );
2537            if ( $user ) {
2538                MediaWikiServices::getInstance()
2539                    ->getTalkPageNotificationManager()
2540                    ->removeUserHasNewMessages( $user );
2541            }
2542        }
2543
2544        // Image redirects
2545        $services->getRepoGroup()->getLocalRepo()->invalidateImageRedirect( $title );
2546
2547        // Purge cross-wiki cache entities referencing this page
2548        self::purgeInterwikiCheckKey( $title );
2549    }
2550
2551    /**
2552     * Purge caches on page update etc
2553     *
2554     * @param Title $title
2555     * @param RevisionRecord|null $revRecord revision that was just saved, may be null
2556     * @param string[]|null $slotsChanged The role names of the slots that were changed.
2557     *        If not given, all slots are assumed to have changed.
2558     * @param bool $maybeRedirectChanged True if the page's redirect target may have changed in the
2559     *   latest revision. If false, this is used as a hint to skip some unnecessary updates.
2560     */
2561    public static function onArticleEdit(
2562        Title $title,
2563        ?RevisionRecord $revRecord = null,
2564        $slotsChanged = null,
2565        $maybeRedirectChanged = true
2566    ) {
2567        // TODO: move this into a PageEventEmitter service
2568
2569        DeferredUpdates::addCallableUpdate(
2570            static function () use ( $title, $slotsChanged, $maybeRedirectChanged ) {
2571                self::queueBacklinksJobs(
2572                    $title,
2573                    $slotsChanged === null || in_array( SlotRecord::MAIN, $slotsChanged ),
2574                    $maybeRedirectChanged,
2575                    'edit-page'
2576                );
2577            }
2578        );
2579
2580        $services = MediaWikiServices::getInstance();
2581        $services->getLinkCache()->invalidateTitle( $title );
2582
2583        $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater();
2584        $hcu->purgeTitleUrls( $title, $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2585
2586        // Purge ?action=info cache
2587        $revid = $revRecord ? $revRecord->getId() : null;
2588        DeferredUpdates::addCallableUpdate( static function () use ( $title, $revid ) {
2589            InfoAction::invalidateCache( $title, $revid );
2590        } );
2591
2592        // Purge cross-wiki cache entities referencing this page
2593        self::purgeInterwikiCheckKey( $title );
2594    }
2595
2596    private static function queueBacklinksJobs(
2597        Title $title, $mainSlotChanged, $maybeRedirectChanged, $causeAction
2598    ) {
2599        $services = MediaWikiServices::getInstance();
2600        $backlinkCache = $services->getBacklinkCacheFactory()->getBacklinkCache( $title );
2601
2602        $jobs = [];
2603        if ( $mainSlotChanged
2604            && $backlinkCache->hasLinks( 'templatelinks' )
2605        ) {
2606            // Invalidate caches of articles which include this page.
2607            // Only for the main slot, because only the main slot is transcluded.
2608            // TODO: MCR: not true for TemplateStyles! [SlotHandler]
2609            $jobs[] = HTMLCacheUpdateJob::newForBacklinks(
2610                $title,
2611                'templatelinks',
2612                [ 'causeAction' => $causeAction ]
2613            );
2614        }
2615        // Images
2616        if ( $maybeRedirectChanged && $title->getNamespace() === NS_FILE
2617            && $backlinkCache->hasLinks( 'imagelinks' )
2618        ) {
2619            // Process imagelinks in case the redirect target has changed
2620            $jobs[] = HTMLCacheUpdateJob::newForBacklinks(
2621                $title,
2622                'imagelinks',
2623                [ 'causeAction' => $causeAction ]
2624            );
2625        }
2626        // Invalidate the caches of all pages which redirect here
2627        if ( $backlinkCache->hasLinks( 'redirect' ) ) {
2628            $jobs[] = HTMLCacheUpdateJob::newForBacklinks(
2629                $title,
2630                'redirect',
2631                [ 'causeAction' => $causeAction ]
2632            );
2633        }
2634        if ( $jobs ) {
2635            $services->getJobQueueGroup()->push( $jobs );
2636        }
2637    }
2638
2639    /**
2640     * Purge the check key for cross-wiki cache entries referencing this page
2641     *
2642     * @param Title $title
2643     */
2644    private static function purgeInterwikiCheckKey( Title $title ) {
2645        $enableScaryTranscluding = MediaWikiServices::getInstance()->getMainConfig()->get(
2646            MainConfigNames::EnableScaryTranscluding );
2647
2648        if ( !$enableScaryTranscluding ) {
2649            return; // @todo: perhaps this wiki is only used as a *source* for content?
2650        }
2651
2652        DeferredUpdates::addCallableUpdate( static function () use ( $title ) {
2653            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
2654            $cache->resetCheckKey(
2655                // Do not include the namespace since there can be multiple aliases to it
2656                // due to different namespace text definitions on different wikis. This only
2657                // means that some cache invalidations happen that are not strictly needed.
2658                $cache->makeGlobalKey(
2659                    'interwiki-page',
2660                    WikiMap::getCurrentWikiDbDomain()->getId(),
2661                    $title->getDBkey()
2662                )
2663            );
2664        } );
2665    }
2666
2667    /**
2668     * Returns a list of categories this page is a member of.
2669     * Results will include hidden categories
2670     *
2671     * @return TitleArrayFromResult
2672     */
2673    public function getCategories() {
2674        $services = MediaWikiServices::getInstance();
2675        $id = $this->getId();
2676        if ( $id == 0 ) {
2677            return $services->getTitleFactory()->newTitleArrayFromResult( new FakeResultWrapper( [] ) );
2678        }
2679
2680        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
2681        $res = $dbr->newSelectQueryBuilder()
2682            ->select( [ 'page_title' => 'cl_to', 'page_namespace' => (string)NS_CATEGORY ] )
2683            ->from( 'categorylinks' )
2684            ->where( [ 'cl_from' => $id ] )
2685            ->caller( __METHOD__ )->fetchResultSet();
2686
2687        return $services->getTitleFactory()->newTitleArrayFromResult( $res );
2688    }
2689
2690    /**
2691     * Returns a list of hidden categories this page is a member of.
2692     * Uses the page_props and categorylinks tables.
2693     *
2694     * @return Title[]
2695     */
2696    public function getHiddenCategories() {
2697        $result = [];
2698        $id = $this->getId();
2699
2700        if ( $id == 0 ) {
2701            return [];
2702        }
2703
2704        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
2705        $res = $dbr->newSelectQueryBuilder()
2706            ->select( [ 'cl_to' ] )
2707            ->from( 'categorylinks' )
2708            ->join( 'page', null, 'page_title=cl_to' )
2709            ->join( 'page_props', null, 'pp_page=page_id' )
2710            ->where( [ 'cl_from' => $id, 'pp_propname' => 'hiddencat', 'page_namespace' => NS_CATEGORY ] )
2711            ->caller( __METHOD__ )->fetchResultSet();
2712
2713        foreach ( $res as $row ) {
2714            $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
2715        }
2716
2717        return $result;
2718    }
2719
2720    /**
2721     * Auto-generates a deletion reason
2722     *
2723     * @param bool &$hasHistory Whether the page has a history
2724     * @return string|false String containing deletion reason or empty string, or boolean false
2725     *    if no revision occurred
2726     */
2727    public function getAutoDeleteReason( &$hasHistory = false ) {
2728        if ( func_num_args() === 1 ) {
2729            wfDeprecated( __METHOD__ . ': $hasHistory parameter', '1.38' );
2730            return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
2731        }
2732        return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle() );
2733    }
2734
2735    /**
2736     * Update all the appropriate counts in the category table, given that
2737     * we've added the categories $added and deleted the categories $deleted.
2738     *
2739     * This should only be called from deferred updates or jobs to avoid contention.
2740     *
2741     * @param string[] $added The names of categories that were added
2742     * @param string[] $deleted The names of categories that were deleted
2743     * @param int $id Page ID (this should be the original deleted page ID)
2744     */
2745    public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
2746        $id = $id ?: $this->getId();
2747        // Guard against data corruption T301433
2748        $added = array_map( 'strval', $added );
2749        $deleted = array_map( 'strval', $deleted );
2750        $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
2751            getCategoryLinkType( $this->getTitle()->getNamespace() );
2752
2753        $addFields = [ 'cat_pages' => new RawSQLValue( 'cat_pages + 1' ) ];
2754        $removeFields = [ 'cat_pages' => new RawSQLValue( 'cat_pages - 1' ) ];
2755        if ( $type !== 'page' ) {
2756            $addFields["cat_{$type}s"] = new RawSQLValue( "cat_{$type}s + 1" );
2757            $removeFields["cat_{$type}s"] = new RawSQLValue( "cat_{$type}s - 1" );
2758        }
2759
2760        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
2761        $res = $dbw->newSelectQueryBuilder()
2762            ->select( [ 'cat_id', 'cat_title' ] )
2763            ->from( 'category' )
2764            ->where( [ 'cat_title' => array_merge( $added, $deleted ) ] )
2765            ->caller( __METHOD__ )
2766            ->fetchResultSet();
2767        $existingCategories = [];
2768        foreach ( $res as $row ) {
2769            $existingCategories[$row->cat_id] = $row->cat_title;
2770        }
2771        $existingAdded = array_intersect( $existingCategories, $added );
2772        $existingDeleted = array_intersect( $existingCategories, $deleted );
2773        $missingAdded = array_diff( $added, $existingAdded );
2774
2775        // For category rows that already exist, do a plain
2776        // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
2777        // to avoid creating gaps in the cat_id sequence.
2778        if ( $existingAdded ) {
2779            $dbw->newUpdateQueryBuilder()
2780                ->update( 'category' )
2781                ->set( $addFields )
2782                ->where( [ 'cat_id' => array_keys( $existingAdded ) ] )
2783                ->caller( __METHOD__ )->execute();
2784        }
2785
2786        if ( $missingAdded ) {
2787            $queryBuilder = $dbw->newInsertQueryBuilder()
2788                ->insertInto( 'category' )
2789                ->onDuplicateKeyUpdate()
2790                ->uniqueIndexFields( [ 'cat_title' ] )
2791                ->set( $addFields );
2792            foreach ( $missingAdded as $cat ) {
2793                $queryBuilder->row( [
2794                    'cat_title'   => $cat,
2795                    'cat_pages'   => 1,
2796                    'cat_subcats' => ( $type === 'subcat' ) ? 1 : 0,
2797                    'cat_files'   => ( $type === 'file' ) ? 1 : 0,
2798                ] );
2799            }
2800            $queryBuilder->caller( __METHOD__ )->execute();
2801        }
2802
2803        if ( $existingDeleted ) {
2804            $dbw->newUpdateQueryBuilder()
2805                ->update( 'category' )
2806                ->set( $removeFields )
2807                ->where( [ 'cat_id' => array_keys( $existingDeleted ) ] )
2808                ->caller( __METHOD__ )->execute();
2809        }
2810
2811        foreach ( $added as $catName ) {
2812            $cat = Category::newFromName( $catName );
2813            $this->getHookRunner()->onCategoryAfterPageAdded( $cat, $this );
2814        }
2815
2816        foreach ( $deleted as $catName ) {
2817            $cat = Category::newFromName( $catName );
2818            $this->getHookRunner()->onCategoryAfterPageRemoved( $cat, $this, $id );
2819            // Refresh counts on categories that should be empty now (after commit, T166757)
2820            DeferredUpdates::addCallableUpdate( static function () use ( $cat ) {
2821                $cat->refreshCountsIfEmpty();
2822            } );
2823        }
2824    }
2825
2826    /**
2827     * Opportunistically enqueue link update jobs after a fresh parser output was generated.
2828     *
2829     * This method should only be called by PoolWorkArticleViewCurrent, after a page view
2830     * experienced a miss from the ParserCache, and a new ParserOutput was generated.
2831     * Specifically, for load reasons, this method must not get called during page views that
2832     * use a cached ParserOutput.
2833     *
2834     * @since 1.25
2835     * @internal For use by PoolWorkArticleViewCurrent
2836     * @param ParserOutput $parserOutput Current version page output
2837     */
2838    public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
2839        if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
2840            return;
2841        }
2842
2843        if ( !$this->getHookRunner()->onOpportunisticLinksUpdate( $this,
2844            $this->mTitle, $parserOutput )
2845        ) {
2846            return;
2847        }
2848
2849        $config = MediaWikiServices::getInstance()->getMainConfig();
2850
2851        $params = [
2852            'isOpportunistic' => true,
2853            'rootJobTimestamp' => $parserOutput->getCacheTime()
2854        ];
2855
2856        if ( MediaWikiServices::getInstance()->getRestrictionStore()->areRestrictionsCascading( $this->mTitle ) ) {
2857            // In general, MediaWiki does not re-run LinkUpdate (e.g. for search index, category
2858            // listings, and backlinks for Whatlinkshere), unless either the page was directly
2859            // edited, or was re-generate following a template edit propagating to an affected
2860            // page. As such, during page views when there is no valid ParserCache entry,
2861            // we re-parse and save, but leave indexes as-is.
2862            //
2863            // We make an exception for pages that have cascading protection (perhaps for a wiki's
2864            // "Main Page"). When such page is re-parsed on-demand after a parser cache miss, we
2865            // queue a high-priority LinksUpdate job, to ensure that we really protect all
2866            // content that is currently transcluded onto the page. This is important, because
2867            // wikitext supports conditional statements based on the current time, which enables
2868            // transcluding of a different subpage based on which day it is, and then show that
2869            // information on the Main Page, without the Main Page itself being edited.
2870            MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush(
2871                RefreshLinksJob::newPrioritized( $this->mTitle, $params )
2872            );
2873        } elseif ( !$config->get( MainConfigNames::MiserMode ) &&
2874            $parserOutput->hasReducedExpiry()
2875        ) {
2876            // Assume the output contains "dynamic" time/random based magic words.
2877            // Only update pages that expired due to dynamic content and NOT due to edits
2878            // to referenced templates/files. When the cache expires due to dynamic content,
2879            // page_touched is unchanged. We want to avoid triggering redundant jobs due to
2880            // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
2881            // template/file edit already triggered recursive RefreshLinksJob jobs.
2882            if ( $this->getLinksTimestamp() > $this->getTouched() ) {
2883                // If a page is uncacheable, do not keep spamming a job for it.
2884                // Although it would be de-duplicated, it would still waste I/O.
2885                $services = MediaWikiServices::getInstance()->getObjectCacheFactory();
2886                $cache = $services->getLocalClusterInstance();
2887                $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
2888                $ttl = max( $parserOutput->getCacheExpiry(), 3600 );
2889                if ( $cache->add( $key, time(), $ttl ) ) {
2890                    MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush(
2891                        RefreshLinksJob::newDynamic( $this->mTitle, $params )
2892                    );
2893                }
2894            }
2895        }
2896    }
2897
2898    /**
2899     * Whether this content displayed on this page
2900     * comes from the local database
2901     *
2902     * @since 1.28
2903     * @return bool
2904     */
2905    public function isLocal() {
2906        return true;
2907    }
2908
2909    /**
2910     * The display name for the site this content
2911     * come from. If a subclass overrides isLocal(),
2912     * this could return something other than the
2913     * current site name
2914     *
2915     * @since 1.28
2916     * @return string
2917     */
2918    public function getWikiDisplayName() {
2919        $sitename = MediaWikiServices::getInstance()->getMainConfig()->get(
2920            MainConfigNames::Sitename );
2921        return $sitename;
2922    }
2923
2924    /**
2925     * Get the source URL for the content on this page,
2926     * typically the canonical URL, but may be a remote
2927     * link if the content comes from another site
2928     *
2929     * @since 1.28
2930     * @return string
2931     */
2932    public function getSourceURL() {
2933        return $this->getTitle()->getCanonicalURL();
2934    }
2935
2936    /**
2937     * Ensure consistency when unserializing.
2938     * @note WikiPage objects should never be serialized in the first place.
2939     * But some extensions like AbuseFilter did (see T213006),
2940     * and we need to be able to read old data (see T187153).
2941     */
2942    public function __wakeup() {
2943        // Make sure we re-fetch the latest state from the database.
2944        // In particular, the latest revision may have changed.
2945        // As a side-effect, this makes sure mLastRevision doesn't
2946        // end up being an instance of the old Revision class (see T259181),
2947        // especially since that class was removed entirely in 1.37.
2948        $this->clear();
2949    }
2950
2951    /**
2952     * @inheritDoc
2953     * @since 1.36
2954     */
2955    public function getNamespace(): int {
2956        return $this->getTitle()->getNamespace();
2957    }
2958
2959    /**
2960     * @inheritDoc
2961     * @since 1.36
2962     */
2963    public function getDBkey(): string {
2964        return $this->getTitle()->getDBkey();
2965    }
2966
2967    /**
2968     * @return false self::LOCAL
2969     * @since 1.36
2970     */
2971    public function getWikiId() {
2972        return $this->getTitle()->getWikiId();
2973    }
2974
2975    /**
2976     * @return true
2977     * @since 1.36
2978     */
2979    public function canExist(): bool {
2980        return true;
2981    }
2982
2983    /**
2984     * @inheritDoc
2985     * @since 1.36
2986     */
2987    public function __toString(): string {
2988        return $this->mTitle->__toString();
2989    }
2990
2991    /**
2992     * @inheritDoc
2993     * @since 1.36
2994     */
2995    public function isSamePageAs( PageReference $other ): bool {
2996        // NOTE: keep in sync with PageReferenceValue::isSamePageAs()!
2997        return $this->getWikiId() === $other->getWikiId()
2998            && $this->getNamespace() === $other->getNamespace()
2999            && $this->getDBkey() === $other->getDBkey();
3000    }
3001
3002    /**
3003     * Returns the page represented by this WikiPage as a PageStoreRecord.
3004     * The PageRecord returned by this method is guaranteed to be immutable.
3005     *
3006     * It is preferred to use this method rather than using the WikiPage as a PageIdentity directly.
3007     * @since 1.36
3008     *
3009     * @throws PreconditionException if the page does not exist.
3010     *
3011     * @return ExistingPageRecord
3012     */
3013    public function toPageRecord(): ExistingPageRecord {
3014        // TODO: replace individual member fields with a PageRecord instance that is always present
3015
3016        if ( !$this->mDataLoaded ) {
3017            $this->loadPageData();
3018        }
3019
3020        Assert::precondition(
3021            $this->exists(),
3022            'This WikiPage instance does not represent an existing page: ' . $this->mTitle
3023        );
3024
3025        return new PageStoreRecord(
3026            (object)[
3027                'page_id' => $this->getId(),
3028                'page_namespace' => $this->mTitle->getNamespace(),
3029                'page_title' => $this->mTitle->getDBkey(),
3030                'page_latest' => $this->mLatest,
3031                'page_is_new' => $this->mIsNew ? 1 : 0,
3032                'page_is_redirect' => $this->mPageIsRedirectField ? 1 : 0,
3033                'page_touched' => $this->getTouched(),
3034                'page_lang' => $this->getLanguage()
3035            ],
3036            PageIdentity::LOCAL
3037        );
3038    }
3039
3040}