Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.40% covered (warning)
86.40%
521 / 603
67.86% covered (warning)
67.86%
38 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
DerivedPageDataUpdater
86.40% covered (warning)
86.40%
521 / 603
67.86% covered (warning)
67.86%
38 / 56
296.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCause
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPerformer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCauseForTracing
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 doTransition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assertTransition
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 isReusableFor
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
24.33
 setForceEmptyRevision
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setArticleCountMethod
100.00% covered (success)
100.00%
1 / 1
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
 getWikiPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pageExisted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParentRevision
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getOldRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 grabCurrentRevision
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 isContentPrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUpdatePrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getPageId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isContentDeleted
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getRawSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRawContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usePrimary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCountable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 isRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 revisionIsRedirect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 prepareContent
82.98% covered (warning)
82.98%
78 / 94
0.00% covered (danger)
0.00%
0 / 1
20.78
 getRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRenderedRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 assertHasPageState
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
4.05
 assertPrepared
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 assertHasRevision
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 isCreation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isChange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 wasRedirect
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getSlots
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionSlotsUpdate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getTouchedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModifiedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRemovedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareUpdate
71.56% covered (warning)
71.56%
78 / 109
0.00% covered (danger)
0.00%
0 / 1
48.35
 getPreparedEdit
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getSlotParserOutput
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getParserOutputForMetaData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSecondaryDataUpdates
95.35% covered (success)
95.35%
41 / 43
0.00% covered (danger)
0.00%
0 / 1
8
 shouldGenerateHTMLOnEdit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doUpdates
85.48% covered (warning)
85.48%
53 / 62
0.00% covered (danger)
0.00%
0 / 1
22.35
 emitEvents
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getPageRevisionUpdatedEvent
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
9.01
 triggerParserCacheUpdate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 maybeAddRecreateChangeTag
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 doSecondaryDataUpdates
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 doParserCacheUpdate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
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
21namespace MediaWiki\Storage;
22
23use InvalidArgumentException;
24use LogicException;
25use MediaWiki\ChangeTags\ChangeTags;
26use MediaWiki\ChangeTags\ChangeTagsStore;
27use MediaWiki\Config\ServiceOptions;
28use MediaWiki\Content\Content;
29use MediaWiki\Content\IContentHandlerFactory;
30use MediaWiki\Content\Transform\ContentTransformer;
31use MediaWiki\Deferred\DeferrableUpdate;
32use MediaWiki\Deferred\DeferredUpdates;
33use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
34use MediaWiki\Deferred\RefreshSecondaryDataUpdate;
35use MediaWiki\Deferred\SiteStatsUpdate;
36use MediaWiki\DomainEvent\DomainEventDispatcher;
37use MediaWiki\Edit\PreparedEdit;
38use MediaWiki\HookContainer\HookContainer;
39use MediaWiki\HookContainer\HookRunner;
40use MediaWiki\JobQueue\JobQueueGroup;
41use MediaWiki\JobQueue\Jobs\ParsoidCachePrewarmJob;
42use MediaWiki\Language\Language;
43use MediaWiki\MainConfigNames;
44use MediaWiki\Page\Event\PageRevisionUpdatedEvent;
45use MediaWiki\Page\PageIdentity;
46use MediaWiki\Page\ParserOutputAccess;
47use MediaWiki\Page\ProperPageIdentity;
48use MediaWiki\Page\WikiPage;
49use MediaWiki\Page\WikiPageFactory;
50use MediaWiki\Parser\ParserCache;
51use MediaWiki\Parser\ParserOptions;
52use MediaWiki\Parser\ParserOutput;
53use MediaWiki\Revision\MutableRevisionRecord;
54use MediaWiki\Revision\RenderedRevision;
55use MediaWiki\Revision\RevisionRecord;
56use MediaWiki\Revision\RevisionRenderer;
57use MediaWiki\Revision\RevisionSlots;
58use MediaWiki\Revision\RevisionStore;
59use MediaWiki\Revision\SlotRecord;
60use MediaWiki\Revision\SlotRoleRegistry;
61use MediaWiki\Title\Title;
62use MediaWiki\User\UserIdentity;
63use MediaWiki\Utils\MWTimestamp;
64use Psr\Log\LoggerAwareInterface;
65use Psr\Log\LoggerInterface;
66use Psr\Log\NullLogger;
67use Wikimedia\Assert\Assert;
68use Wikimedia\ObjectCache\WANObjectCache;
69use Wikimedia\Rdbms\IDBAccessObject;
70use Wikimedia\Rdbms\ILBFactory;
71
72/**
73 * A handle for managing updates for derived page data on edit, import, purge, etc.
74 *
75 * @note Avoid direct usage of DerivedPageDataUpdater.
76 *
77 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
78 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
79 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
80 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
81 * Content::getSecondaryDataUpdates().
82 *
83 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
84 * and re-used by callback code over the course of an update operation. It's a stepping stone
85 * on the way to a more complete refactoring of WikiPage.
86 *
87 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
88 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
89 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
90 * require prepareContent or prepareUpdate to have been called first, to initialize the
91 * DerivedPageDataUpdater.
92 *
93 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
94 * of PreparedEdit.
95 *
96 * @see docs/pageupdater.md for more information.
97 *
98 * @internal
99 * @since 1.32
100 * @ingroup Page
101 */
102class DerivedPageDataUpdater implements LoggerAwareInterface, PreparedUpdate {
103
104    /**
105     * @var UserIdentity|null
106     */
107    private $user = null;
108
109    /**
110     * @var WikiPage
111     */
112    private $wikiPage;
113
114    /**
115     * @var ParserCache
116     */
117    private $parserCache;
118
119    /**
120     * @var RevisionStore
121     */
122    private $revisionStore;
123
124    /**
125     * @var Language
126     */
127    private $contLang;
128
129    /**
130     * @var JobQueueGroup
131     */
132    private $jobQueueGroup;
133
134    /**
135     * @var ILBFactory
136     */
137    private $loadbalancerFactory;
138
139    /**
140     * @var HookRunner
141     */
142    private $hookRunner;
143
144    /**
145     * @var DomainEventDispatcher
146     */
147    private $eventDispatcher;
148
149    /**
150     * @var LoggerInterface
151     */
152    private $logger;
153
154    /**
155     * @var string see $wgArticleCountMethod
156     */
157    private $articleCountMethod;
158
159    /**
160     * Stores (most of) the $options parameter of prepareUpdate().
161     * @see prepareUpdate()
162     *
163     * @var array
164     * @phpcs:ignore Generic.Files.LineLength
165     * @phan-var array{changed:bool,created:bool,moved:bool,cause:string,oldrevision:null|RevisionRecord,triggeringUser:null|UserIdentity,oldredirect:bool|null|string,oldcountable:bool|null|string,causeAction:null|string,causeAgent:null|string,editResult:null|EditResult}
166     */
167    private $options = [
168        'changed' => true,
169        // newrev is true if prepareUpdate is handling the creation of a new revision,
170        // as opposed to a null edit or a forced update.
171        'newrev' => false,
172        'created' => false,
173        'oldtitle' => null,
174        'oldrevision' => null,
175        'oldcountable' => null,
176        'oldredirect' => null,
177        'triggeringUser' => null,
178        // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
179        // to make the life of prepareUpdate() callers easier.
180        'causeAction' => null,
181        'causeAgent' => null,
182        'editResult' => null,
183        'rcPatrolStatus' => 0,
184        'tags' => [],
185        'cause' => 'edit',
186        'emitEvents' => true,
187    ] + PageRevisionUpdatedEvent::DEFAULT_FLAGS;
188
189    /**
190     * The state of the relevant row in page table before the edit.
191     * This is determined by the first call to grabCurrentRevision, prepareContent,
192     * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
193     * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
194     * attempt to emulate the state of the page table before the edit.
195     *
196     * Contains the following fields:
197     * - oldRevision (RevisionRecord|null): the revision that was current before the change
198     *   associated with this update. Might not be set, use getParentRevision().
199     * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
200     *   was about creating a new page); null if not known (that should not happen).
201     * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
202     *   can be null; use wasRedirect() instead of direct access.
203     * - oldCountable (bool|null): whether the page was countable before the change (or null
204     *   if we don't have that information)
205     * - oldRecord (ExistingPageRecord|null): the page record before the update (or null
206     *   if the page didn't exist)
207     *
208     * @var array
209     */
210    private $pageState = null;
211
212    /**
213     * @var RevisionSlotsUpdate|null
214     */
215    private $slotsUpdate = null;
216
217    /**
218     * @var RevisionRecord|null
219     */
220    private $parentRevision = null;
221
222    /**
223     * @var RevisionRecord|null
224     */
225    private $revision = null;
226
227    /**
228     * @var RenderedRevision
229     */
230    private $renderedRevision = null;
231
232    /** @var ?PageRevisionUpdatedEvent */
233    private $pageUpdatedEvent = null;
234
235    /**
236     * @var RevisionRenderer
237     */
238    private $revisionRenderer;
239
240    /** @var SlotRoleRegistry */
241    private $slotRoleRegistry;
242
243    /**
244     * @var bool Whether null-edits create a revision.
245     */
246    private $forceEmptyRevision = false;
247
248    /**
249     * A stage identifier for managing the life cycle of this instance.
250     * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
251     *
252     * @see docs/pageupdater.md for documentation of the life cycle.
253     *
254     * @var string
255     */
256    private $stage = 'new';
257
258    /**
259     * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
260     *
261     * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
262     * and constants are also overkill...
263     *
264     * @see docs/pageupdater.md for documentation of the life cycle.
265     */
266    private const TRANSITIONS = [
267        'new' => [
268            'new' => true,
269            'knows-current' => true,
270            'has-content' => true,
271            'has-revision' => true,
272        ],
273        'knows-current' => [
274            'knows-current' => true,
275            'has-content' => true,
276            'has-revision' => true,
277        ],
278        'has-content' => [
279            'has-content' => true,
280            'has-revision' => true,
281        ],
282        'has-revision' => [
283            'has-revision' => true,
284            'done' => true,
285        ],
286    ];
287
288    /** @var IContentHandlerFactory */
289    private $contentHandlerFactory;
290
291    /** @var EditResultCache */
292    private $editResultCache;
293
294    /** @var ContentTransformer */
295    private $contentTransformer;
296
297    /** @var PageEditStash */
298    private $pageEditStash;
299
300    /** @var WANObjectCache */
301    private $mainWANObjectCache;
302
303    /** @var bool */
304    private $warmParsoidParserCache;
305
306    /** @var bool */
307    private $useRcPatrol;
308
309    private ChangeTagsStore $changeTagsStore;
310
311    public function __construct(
312        ServiceOptions $options,
313        PageIdentity $page,
314        RevisionStore $revisionStore,
315        RevisionRenderer $revisionRenderer,
316        SlotRoleRegistry $slotRoleRegistry,
317        ParserCache $parserCache,
318        JobQueueGroup $jobQueueGroup,
319        Language $contLang,
320        ILBFactory $loadbalancerFactory,
321        IContentHandlerFactory $contentHandlerFactory,
322        HookContainer $hookContainer,
323        DomainEventDispatcher $eventDispatcher,
324        EditResultCache $editResultCache,
325        ContentTransformer $contentTransformer,
326        PageEditStash $pageEditStash,
327        WANObjectCache $mainWANObjectCache,
328        WikiPageFactory $wikiPageFactory,
329        ChangeTagsStore $changeTagsStore
330    ) {
331        // TODO: Remove this cast eventually
332        $this->wikiPage = $wikiPageFactory->newFromTitle( $page );
333
334        $this->parserCache = $parserCache;
335        $this->revisionStore = $revisionStore;
336        $this->revisionRenderer = $revisionRenderer;
337        $this->slotRoleRegistry = $slotRoleRegistry;
338        $this->jobQueueGroup = $jobQueueGroup;
339        $this->contLang = $contLang;
340        // XXX only needed for waiting for replicas to catch up; there should be a narrower
341        // interface for that.
342        $this->loadbalancerFactory = $loadbalancerFactory;
343        $this->contentHandlerFactory = $contentHandlerFactory;
344        $this->hookRunner = new HookRunner( $hookContainer );
345        $this->eventDispatcher = $eventDispatcher;
346        $this->editResultCache = $editResultCache;
347        $this->contentTransformer = $contentTransformer;
348        $this->pageEditStash = $pageEditStash;
349        $this->mainWANObjectCache = $mainWANObjectCache;
350        $this->changeTagsStore = $changeTagsStore;
351
352        $this->logger = new NullLogger();
353
354        $this->warmParsoidParserCache = $options
355            ->get( MainConfigNames::ParsoidCacheConfig )['WarmParsoidParserCache'];
356        $this->useRcPatrol = $options
357            ->get( MainConfigNames::UseRCPatrol );
358    }
359
360    public function setLogger( LoggerInterface $logger ) {
361        $this->logger = $logger;
362    }
363
364    /**
365     * Set the cause of the update. Will be used for the PageRevisionUpdatedEvent
366     * and for tracing/logging in jobs, etc.
367     *
368     * @param string $cause See PageRevisionUpdatedEvent::CAUSE_XXX
369     *
370     * @return void
371     */
372    public function setCause( string $cause ) {
373        // 'cause' is for use in PageRevisionUpdatedEvent, 'causeAction' is for
374        // use in tracing in updates, jobs, and RevisionRenderer.
375        // Note that PageRevisionUpdatedEvent uses causes like "edit" and "move", but
376        // the convention for causeAction is to use "page-edit", etc.
377        $this->options['cause'] = $cause;
378        $this->options['causeAction'] = 'page-' . $cause;
379    }
380
381    /**
382     * Set the performer of the action.
383     *
384     * @return void
385     */
386    public function setPerformer( UserIdentity $performer ) {
387        $this->options['triggeringUser'] = $performer;
388        $this->options['causeAgent'] = $performer->getName();
389    }
390
391    /**
392     * @return string[] [ $causeAction, $causeAgent ]
393     */
394    private function getCauseForTracing(): array {
395        return [
396            $this->options['causeAction'] ?? 'unknown',
397            $this->options['causeAgent']
398                ?? ( $this->user ? $this->user->getName() : 'unknown' ),
399        ];
400    }
401
402    /**
403     * Transition function for managing the life cycle of this instances.
404     *
405     * @see docs/pageupdater.md for documentation of the life cycle.
406     *
407     * @param string $newStage
408     * @return string the previous stage
409     */
410    private function doTransition( $newStage ) {
411        $this->assertTransition( $newStage );
412
413        $oldStage = $this->stage;
414        $this->stage = $newStage;
415
416        return $oldStage;
417    }
418
419    /**
420     * Asserts that a transition to the given stage is possible, without performing it.
421     *
422     * @see docs/pageupdater.md for documentation of the life cycle.
423     *
424     * @param string $newStage
425     */
426    private function assertTransition( $newStage ) {
427        if ( empty( self::TRANSITIONS[$this->stage][$newStage] ) ) {
428            throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
429        }
430    }
431
432    /**
433     * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
434     * the given revision.
435     *
436     * @param UserIdentity|null $user The user creating the revision in question
437     * @param RevisionRecord|null $revision New revision (after save, if already saved)
438     * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
439     * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
440     *
441     * @return bool
442     */
443    public function isReusableFor(
444        ?UserIdentity $user = null,
445        ?RevisionRecord $revision = null,
446        ?RevisionSlotsUpdate $slotsUpdate = null,
447        $parentId = null
448    ) {
449        if ( $revision
450            && $parentId
451            && $revision->getParentId() !== $parentId
452        ) {
453            throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
454        }
455
456        // NOTE: For null revisions, $user may be different from $this->revision->getUser
457        // and also from $revision->getUser.
458        // But $user should always match $this->user.
459        if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
460            return false;
461        }
462
463        if ( $revision && $this->revision && $this->revision->getId()
464            && $this->revision->getId() !== $revision->getId()
465        ) {
466            return false;
467        }
468
469        if ( $this->pageState
470            && $revision
471            && $revision->getParentId() !== null
472            && $this->pageState['oldId'] !== $revision->getParentId()
473        ) {
474            return false;
475        }
476
477        if ( $this->pageState
478            && $parentId !== null
479            && $this->pageState['oldId'] !== $parentId
480        ) {
481            return false;
482        }
483
484        // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
485        if ( $this->slotsUpdate
486            && $slotsUpdate
487            && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
488        ) {
489            return false;
490        }
491
492        if ( $revision
493            && $this->revision
494            && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
495        ) {
496            return false;
497        }
498
499        return true;
500    }
501
502    /**
503     * Set whether null-edits should create a revision. Enabling this allows the creation of dummy
504     * revisions ("null revisions") to mark events such as renaming in the page history.
505     *
506     * Must not be called once prepareContent() or prepareUpdate() have been called.
507     *
508     * @since 1.38
509     * @see PageUpdater setForceEmptyRevision
510     *
511     * @param bool $forceEmptyRevision
512     */
513    public function setForceEmptyRevision( bool $forceEmptyRevision ) {
514        if ( $this->revision ) {
515            throw new LogicException( 'prepareContent() or prepareUpdate() was already called.' );
516        }
517
518        $this->forceEmptyRevision = $forceEmptyRevision;
519    }
520
521    /**
522     * @param string $articleCountMethod "any" or "link".
523     * @see $wgArticleCountMethod
524     */
525    public function setArticleCountMethod( $articleCountMethod ) {
526        $this->articleCountMethod = $articleCountMethod;
527    }
528
529    /**
530     * @return Title
531     */
532    private function getTitle() {
533        // NOTE: eventually, this won't use WikiPage any more
534        return $this->wikiPage->getTitle();
535    }
536
537    /**
538     * @return WikiPage
539     */
540    private function getWikiPage() {
541        // NOTE: eventually, this won't use WikiPage any more
542        return $this->wikiPage;
543    }
544
545    /**
546     * Returns the page being updated.
547     * @since 1.37
548     * @return ProperPageIdentity (narrowed to ProperPageIdentity in 1.44)
549     */
550    public function getPage(): ProperPageIdentity {
551        return $this->wikiPage;
552    }
553
554    /**
555     * Determines whether the page being edited already existed.
556     * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
557     *
558     * @return bool
559     * @throws LogicException if called before grabCurrentRevision
560     */
561    public function pageExisted() {
562        $this->assertHasPageState( __METHOD__ );
563
564        return $this->pageState['oldId'] > 0;
565    }
566
567    /**
568     * Returns the parent revision of the new revision wrapped by this update.
569     * If the update is a null-edit, this will return the parent of the current (and new) revision.
570     * This will return null if the revision wrapped by this update created the page.
571     * Only defined after calling prepareContent() or prepareUpdate()!
572     *
573     * @return RevisionRecord|null the parent revision of the new revision, or null if
574     *         the update created the page.
575     */
576    private function getParentRevision() {
577        $this->assertPrepared( __METHOD__ );
578
579        if ( $this->parentRevision ) {
580            return $this->parentRevision;
581        }
582
583        if ( !$this->pageState['oldId'] ) {
584            // If there was no current revision, there is no parent revision,
585            // since the page didn't exist.
586            return null;
587        }
588
589        $oldId = $this->revision->getParentId();
590        $flags = $this->usePrimary() ? IDBAccessObject::READ_LATEST : 0;
591        $this->parentRevision = $oldId
592            ? $this->revisionStore->getRevisionById( $oldId, $flags )
593            : null;
594
595        return $this->parentRevision;
596    }
597
598    /**
599     * Returns the revision that was the page's current revision when grabCurrentRevision()
600     * was first called.
601     *
602     * @return RevisionRecord|null the original revision before the update, or null
603     *         if the page did not yet exist.
604     */
605    private function getOldRevision() {
606        $this->assertPrepared( __METHOD__ );
607        return $this->pageState['oldRevision'];
608    }
609
610    /**
611     * Returns the revision that was the page's current revision when grabCurrentRevision()
612     * was first called.
613     *
614     * During an edit, that revision will act as the logical parent of the new revision.
615     *
616     * Some updates are performed based on the difference between the database state at the
617     * moment this method is first called, and the state after the edit.
618     *
619     * @see docs/pageupdater.md for more information on when thie method can and should be called.
620     *
621     * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
622     * to avoid confusion, since the page's current revision is then the new revision after
623     * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
624     * Use getParentRevision() instead to access the revision that is the parent of the
625     * new revision.
626     *
627     * @return RevisionRecord|null the page's current revision, or null if the page does not
628     * yet exist.
629     */
630    public function grabCurrentRevision() {
631        if ( $this->pageState ) {
632            return $this->pageState['oldRevision'];
633        }
634
635        $this->assertTransition( 'knows-current' );
636
637        // NOTE: eventually, this won't use WikiPage any more
638        $wikiPage = $this->getWikiPage();
639
640        // Do not call WikiPage::clear(), since the caller may already have caused page data
641        // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
642        $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
643        $current = $wikiPage->getRevisionRecord();
644
645        $this->pageState = [
646            'oldRevision' => $current,
647            'oldId' => $current ? $current->getId() : 0,
648            'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
649            'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
650            'oldRecord' => $wikiPage->exists() ? $wikiPage->toPageRecord() : null,
651        ];
652
653        $this->doTransition( 'knows-current' );
654
655        return $this->pageState['oldRevision'];
656    }
657
658    /**
659     * Whether prepareUpdate() or prepareContent() have been called on this instance.
660     *
661     * @return bool
662     */
663    public function isContentPrepared() {
664        return $this->revision !== null;
665    }
666
667    /**
668     * Whether prepareUpdate() has been called on this instance.
669     *
670     * @note will also return null in case of a null-edit!
671     *
672     * @return bool
673     */
674    public function isUpdatePrepared() {
675        return $this->revision !== null && $this->revision->getId() !== null;
676    }
677
678    /**
679     * @return int
680     */
681    private function getPageId() {
682        // NOTE: eventually, this won't use WikiPage any more
683        return $this->wikiPage->getId();
684    }
685
686    /**
687     * Whether the content is deleted and thus not visible to the public.
688     *
689     * @return bool
690     */
691    public function isContentDeleted() {
692        if ( $this->revision ) {
693            return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
694        } else {
695            // If the content has not been saved yet, it cannot have been deleted yet.
696            return false;
697        }
698    }
699
700    /**
701     * Returns the slot, modified or inherited, after PST, with no audience checks applied.
702     *
703     * @param string $role slot role name
704     *
705     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
706     *        parent revision.
707     * @return SlotRecord
708     */
709    public function getRawSlot( $role ) {
710        return $this->getSlots()->getSlot( $role );
711    }
712
713    /**
714     * Returns the content of the given slot, with no audience checks.
715     *
716     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
717     *        parent revision.
718     * @param string $role slot role name
719     * @return Content
720     */
721    public function getRawContent( string $role ): Content {
722        return $this->getRawSlot( $role )->getContent();
723    }
724
725    private function usePrimary(): bool {
726        // TODO: can we just set a flag to true in prepareContent()?
727        return $this->wikiPage->wasLoadedFrom( IDBAccessObject::READ_LATEST );
728    }
729
730    public function isCountable(): bool {
731        // NOTE: Keep in sync with WikiPage::isCountable.
732
733        if ( !$this->getTitle()->isContentPage() ) {
734            return false;
735        }
736
737        if ( $this->isContentDeleted() ) {
738            // This should be irrelevant: countability only applies to the current revision,
739            // and the current revision is never suppressed.
740            return false;
741        }
742
743        if ( $this->isRedirect() ) {
744            return false;
745        }
746
747        $hasLinks = null;
748
749        if ( $this->articleCountMethod === 'link' ) {
750            // NOTE: it would be more appropriate to determine for each slot separately
751            // whether it has links, and use that information with that slot's
752            // isCountable() method. However, that would break parity with
753            // WikiPage::isCountable, which uses the pagelinks table to determine
754            // whether the current revision has links.
755            $hasLinks = $this->getParserOutputForMetaData()->hasLinks();
756        }
757
758        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
759            $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
760            if ( $roleHandler->supportsArticleCount() ) {
761                $content = $this->getRawContent( $role );
762
763                if ( $content->isCountable( $hasLinks ) ) {
764                    return true;
765                }
766            }
767        }
768
769        return false;
770    }
771
772    public function isRedirect(): bool {
773        // NOTE: main slot determines redirect status
774        // TODO: MCR: this should be controlled by a PageTypeHandler
775        $mainContent = $this->getRawContent( SlotRecord::MAIN );
776
777        return $mainContent->isRedirect();
778    }
779
780    /**
781     * @param RevisionRecord $rev
782     *
783     * @return bool
784     */
785    private function revisionIsRedirect( RevisionRecord $rev ) {
786        // NOTE: main slot determines redirect status
787        $mainContent = $rev->getMainContentRaw();
788
789        return $mainContent->isRedirect();
790    }
791
792    /**
793     * Prepare updates based on an update which has not yet been saved.
794     *
795     * This may be used to create derived data that is needed when creating a new revision;
796     * particularly, this makes available the slots of the new revision via the getSlots()
797     * method, after applying PST and slot inheritance.
798     *
799     * The derived data prepared for revision creation may then later be re-used by doUpdates(),
800     * without the need to re-calculate.
801     *
802     * @see docs/pageupdater.md for more information on when thie method can and should be called.
803     *
804     * @note Calling this method more than once with the same $slotsUpdate
805     * has no effect. Calling this method multiple times with different content will cause
806     * an exception.
807     *
808     * @note Calling this method after prepareUpdate() has been called will cause an exception.
809     *
810     * @param UserIdentity $user The user to act as context for pre-save transformation (PST).
811     * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
812     *        by this edit, before PST.
813     * @param bool $useStash Whether to use stashed ParserOutput
814     */
815    public function prepareContent(
816        UserIdentity $user,
817        RevisionSlotsUpdate $slotsUpdate,
818        $useStash = true
819    ) {
820        if ( $this->slotsUpdate ) {
821            if ( !$this->user ) {
822                throw new LogicException(
823                    'Unexpected state: $this->slotsUpdate was initialized, '
824                    . 'but $this->user was not.'
825                );
826            }
827
828            if ( $this->user->getName() !== $user->getName() ) {
829                throw new LogicException( 'Can\'t call prepareContent() again for different user! '
830                    . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
831                );
832            }
833
834            if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
835                throw new LogicException(
836                    'Can\'t call prepareContent() again with different slot content!'
837                );
838            }
839
840            return; // prepareContent() already done, nothing to do
841        }
842
843        $this->assertTransition( 'has-content' );
844
845        $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
846        $title = $this->getTitle();
847
848        $parentRevision = $this->grabCurrentRevision();
849
850        // The edit may have already been prepared via api.php?action=stashedit
851        $stashedEdit = false;
852
853        // TODO: MCR: allow output for all slots to be stashed.
854        if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
855            $stashedEdit = $this->pageEditStash->checkCache(
856                $title,
857                $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
858                $user
859            );
860        }
861
862        $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
863        $userPopts->setRenderReason( $this->options['causeAgent'] ?? 'unknown' );
864
865        $this->hookRunner->onArticlePrepareTextForEdit( $wikiPage, $userPopts );
866
867        $this->user = $user;
868        $this->slotsUpdate = $slotsUpdate;
869
870        if ( $parentRevision ) {
871            $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
872        } else {
873            $this->revision = new MutableRevisionRecord( $title );
874        }
875
876        // NOTE: user and timestamp must be set, so they can be used for
877        // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
878        $this->revision->setTimestamp( MWTimestamp::now( TS_MW ) );
879        $this->revision->setUser( $user );
880
881        // Set up ParserOptions to operate on the new revision
882        $oldCallback = $userPopts->getCurrentRevisionRecordCallback();
883        $userPopts->setCurrentRevisionRecordCallback(
884            function ( Title $parserTitle, $parser = null ) use ( $title, $oldCallback ) {
885                if ( $parserTitle->equals( $title ) ) {
886                    return $this->revision;
887                } else {
888                    return $oldCallback( $parserTitle, $parser );
889                }
890            }
891        );
892
893        $pstContentSlots = $this->revision->getSlots();
894
895        foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
896            $slot = $slotsUpdate->getModifiedSlot( $role );
897
898            if ( $slot->isInherited() ) {
899                // No PST for inherited slots! Note that "modified" slots may still be inherited
900                // from an earlier version, e.g. for rollbacks.
901                $pstSlot = $slot;
902            } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
903                // TODO: MCR: allow PST content for all slots to be stashed.
904                $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
905            } else {
906                $pstContent = $this->contentTransformer->preSaveTransform(
907                    $slot->getContent(),
908                    $title,
909                    $user,
910                    $userPopts
911                );
912
913                $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
914            }
915
916            $pstContentSlots->setSlot( $pstSlot );
917        }
918
919        foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
920            $pstContentSlots->removeSlot( $role );
921        }
922
923        $this->options['created'] = ( $parentRevision === null );
924        $this->options['changed'] = ( $parentRevision === null
925            || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
926
927        $this->doTransition( 'has-content' );
928
929        if ( !$this->options['changed'] ) {
930            if ( $this->forceEmptyRevision ) {
931                // dummy revision, inherit all slots
932                foreach ( $parentRevision->getSlotRoles() as $role ) {
933                    $this->revision->inheritSlot( $parentRevision->getSlot( $role ) );
934                }
935            } else {
936                // null-edit, the new revision *is* the old revision.
937
938                // TODO: move this into MutableRevisionRecord
939                $this->revision->setId( $parentRevision->getId() );
940                $this->revision->setTimestamp( $parentRevision->getTimestamp() );
941                $this->revision->setPageId( $parentRevision->getPageId() );
942                $this->revision->setParentId( $parentRevision->getParentId() );
943                $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
944                $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
945                $this->revision->setMinorEdit( $parentRevision->isMinor() );
946                $this->revision->setVisibility( $parentRevision->getVisibility() );
947
948                // prepareUpdate() is redundant for null-edits (but not for dummy revisions)
949                $this->doTransition( 'has-revision' );
950            }
951        } else {
952            $this->parentRevision = $parentRevision;
953        }
954
955        $renderHints = [ 'use-master' => $this->usePrimary(), 'audience' => RevisionRecord::RAW ];
956
957        if ( $stashedEdit ) {
958            /** @var ParserOutput $output */
959            $output = $stashedEdit->output;
960            // TODO: this should happen when stashing the ParserOutput, not now!
961            $output->setCacheTime( $stashedEdit->timestamp );
962
963            $renderHints['known-revision-output'] = $output;
964
965            $this->logger->debug( __METHOD__ . ': using stashed edit output...' );
966        }
967
968        $renderHints['generate-html'] = $this->shouldGenerateHTMLOnEdit();
969
970        [ $causeAction, ] = $this->getCauseForTracing();
971        $renderHints['causeAction'] = $causeAction;
972
973            // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
974        // NOTE: the revision is either new or current, so we can bypass audience checks.
975        $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
976            $this->revision,
977            null,
978            null,
979            $renderHints
980        );
981    }
982
983    /**
984     * Returns the update's target revision - that is, the revision that will be the current
985     * revision after the update.
986     *
987     * @note Callers must treat the returned RevisionRecord's content as immutable, even
988     * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
989     * returned from here, such as the user or the comment, may be changed, but may not
990     * be reflected in ParserOutput until after prepareUpdate() has been called.
991     *
992     * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
993     * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
994     * for that purpose instead!
995     *
996     * @return RevisionRecord
997     */
998    public function getRevision(): RevisionRecord {
999        $this->assertPrepared( __METHOD__ );
1000        return $this->revision;
1001    }
1002
1003    public function getRenderedRevision(): RenderedRevision {
1004        $this->assertPrepared( __METHOD__ );
1005
1006        return $this->renderedRevision;
1007    }
1008
1009    private function assertHasPageState( string $method ) {
1010        if ( !$this->pageState ) {
1011            throw new LogicException(
1012                'Must call grabCurrentRevision() or prepareContent() '
1013                . 'or prepareUpdate() before calling ' . $method
1014            );
1015        }
1016    }
1017
1018    private function assertPrepared( string $method ) {
1019        if ( !$this->revision ) {
1020            throw new LogicException(
1021                'Must call prepareContent() or prepareUpdate() before calling ' . $method
1022            );
1023        }
1024    }
1025
1026    private function assertHasRevision( string $method ) {
1027        if ( !$this->revision->getId() ) {
1028            throw new LogicException(
1029                'Must call prepareUpdate() before calling ' . $method
1030            );
1031        }
1032    }
1033
1034    /**
1035     * Whether the edit creates the page.
1036     *
1037     * @return bool
1038     */
1039    public function isCreation() {
1040        $this->assertPrepared( __METHOD__ );
1041        return $this->options['created'];
1042    }
1043
1044    /**
1045     * Whether the content of the current revision after the edit is different from the content of the
1046     * current revision before the edit. This will return false for a null-edit (no revision created),
1047     * as well as for a dummy revision (a "null-revision" that has the same content as its parent).
1048     *
1049     * @warning at present, dummy revision would return false after prepareContent(),
1050     * but true after prepareUpdate()!
1051     *
1052     * @todo This should probably be fixed.
1053     *
1054     * @return bool
1055     */
1056    public function isChange() {
1057        $this->assertPrepared( __METHOD__ );
1058        return $this->options['changed'];
1059    }
1060
1061    /**
1062     * Whether the page was a redirect before the edit.
1063     *
1064     * @return bool
1065     */
1066    public function wasRedirect() {
1067        $this->assertHasPageState( __METHOD__ );
1068
1069        if ( $this->pageState['oldIsRedirect'] === null ) {
1070            /** @var RevisionRecord $rev */
1071            $rev = $this->pageState['oldRevision'];
1072            if ( $rev ) {
1073                $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
1074            } else {
1075                $this->pageState['oldIsRedirect'] = false;
1076            }
1077        }
1078
1079        return $this->pageState['oldIsRedirect'];
1080    }
1081
1082    /**
1083     * Returns the slots of the target revision, after PST.
1084     *
1085     * @note Callers must treat the returned RevisionSlots instance as immutable, even
1086     * if it is a MutableRevisionSlots instance.
1087     *
1088     * @return RevisionSlots
1089     */
1090    public function getSlots() {
1091        $this->assertPrepared( __METHOD__ );
1092        return $this->revision->getSlots();
1093    }
1094
1095    /**
1096     * Returns the RevisionSlotsUpdate for this updater.
1097     *
1098     * @return RevisionSlotsUpdate
1099     */
1100    private function getRevisionSlotsUpdate() {
1101        $this->assertPrepared( __METHOD__ );
1102
1103        if ( !$this->slotsUpdate ) {
1104            $old = $this->getParentRevision();
1105            $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
1106                $this->revision->getSlots(),
1107                $old ? $old->getSlots() : null
1108            );
1109        }
1110        return $this->slotsUpdate;
1111    }
1112
1113    /**
1114     * Returns the role names of the slots touched by the new revision,
1115     * including removed roles.
1116     *
1117     * @return string[]
1118     */
1119    public function getTouchedSlotRoles() {
1120        return $this->getRevisionSlotsUpdate()->getTouchedRoles();
1121    }
1122
1123    /**
1124     * Returns the role names of the slots modified by the new revision,
1125     * not including removed roles.
1126     *
1127     * @return string[]
1128     */
1129    public function getModifiedSlotRoles(): array {
1130        return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1131    }
1132
1133    /**
1134     * Returns the role names of the slots removed by the new revision.
1135     *
1136     * @return string[]
1137     */
1138    public function getRemovedSlotRoles(): array {
1139        return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1140    }
1141
1142    /**
1143     * Prepare derived data updates targeting the given RevisionRecord.
1144     *
1145     * Calling this method requires the given revision to be present in the database.
1146     * This may be right after a new revision has been created, or when re-generating
1147     * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1148     * script.
1149     *
1150     * @see docs/pageupdater.md for more information on when thie method can and should be called.
1151     *
1152     * @note Calling this method more than once with the same revision has no effect.
1153     * $options are only used for the first call. Calling this method multiple times with
1154     * different revisions will cause an exception.
1155     *
1156     * @note If grabCurrentRevision() (or prepareContent()) has been called before
1157     * calling this method, $revision->getParentRevision() has to refer to the revision that
1158     * was the current revision at the time grabCurrentRevision() was called.
1159     *
1160     * @param RevisionRecord $revision
1161     * @param array $options Array of options. Supports the flags defined by
1162     * PageRevisionUpdatedEvent. In addition, the following keys are supported used:
1163     * - oldtitle: PageIdentity, if the page was moved this is the source title (default null)
1164     * - oldrevision: RevisionRecord object for the pre-update revision (default null)
1165     * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1166     *   user who created the revision)
1167     * - oldredirect: bool, null, or string 'no-change' (default null):
1168     *    - bool: whether the page was counted as a redirect before that
1169     *      revision, only used in changed is true and created is false
1170     *    - null or 'no-change': don't update the redirect status.
1171     * - oldcountable: bool, null, or string 'no-change' (default null):
1172     *    - bool: whether the page was counted as an article before that
1173     *      revision, only used in changed is true and created is false
1174     *    - null: if created is false, don't update the article count; if created
1175     *      is true, do update the article count
1176     *    - 'no-change': don't update the article count, ever
1177     *    When set to null, pageState['oldCountable'] will be used instead if available.
1178     *  - cause: the reason for the update, see PageRevisionUpdatedEvent::CAUSE_XXX.
1179     *  - known-revision-output: a combined canonical ParserOutput for the revision, perhaps
1180     *    from some cache. The caller is responsible for ensuring that the ParserOutput indeed
1181     *    matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
1182     *    for the time until caches have been changed to store RenderedRevision states instead
1183     *    of ParserOutput objects. (default: null) (since 1.33)
1184     *  - editResult: EditResult object created during the update. Required to perform reverted
1185     *    tag update using RevertedTagUpdateJob. (default: null) (since 1.36)
1186     */
1187    public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1188        Assert::parameter(
1189            !isset( $options['oldrevision'] )
1190            || $options['oldrevision'] instanceof RevisionRecord,
1191            '$options["oldrevision"]',
1192            'must be a RevisionRecord'
1193        );
1194        Assert::parameter(
1195            !isset( $options['triggeringUser'] )
1196            || $options['triggeringUser'] instanceof UserIdentity,
1197            '$options["triggeringUser"]',
1198            'must be a UserIdentity'
1199        );
1200        Assert::parameter(
1201            !isset( $options['editResult'] )
1202            || $options['editResult'] instanceof EditResult,
1203            '$options["editResult"]',
1204            'must be an EditResult'
1205        );
1206
1207        if ( !$revision->getId() ) {
1208            throw new InvalidArgumentException(
1209                'Revision must have an ID set for it to be used with prepareUpdate()!'
1210            );
1211        }
1212
1213        if ( !$this->wikiPage->exists() ) {
1214            // If the ongoing edit is creating the page, the state of $this->wikiPage
1215            // may be out of whack. This would only happen if the page creation was
1216            // done using a different WikiPage instance, which shouldn't be the case.
1217            $this->logger->warning(
1218                __METHOD__ . ': Reloading page meta-data after page creation',
1219                [
1220                    'page' => (string)$this->wikiPage,
1221                    'rev_id' => $revision->getId(),
1222                ]
1223            );
1224
1225            $this->wikiPage->clear();
1226            $this->wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
1227        }
1228
1229        if ( $this->revision && $this->revision->getId() ) {
1230            if ( $this->revision->getId() === $revision->getId() ) {
1231                $this->options['changed'] = false; // null-edit
1232            } else {
1233                throw new LogicException(
1234                    'Trying to re-use DerivedPageDataUpdater with revision '
1235                    . $revision->getId()
1236                    . ', but it\'s already bound to revision '
1237                    . $this->revision->getId()
1238                );
1239            }
1240        }
1241
1242        if ( $this->revision
1243            && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1244        ) {
1245            throw new LogicException(
1246                'The revision provided has mismatching content!'
1247            );
1248        }
1249
1250        // Override fields defined in $this->options with values from $options.
1251        $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1252
1253        if ( $this->revision ) {
1254            $oldId = $this->pageState['oldId'] ?? 0;
1255            $this->options['newrev'] = ( $revision->getId() !== $oldId );
1256        } elseif ( isset( $this->options['oldrevision'] ) ) {
1257            /** @var RevisionRecord $oldRev */
1258            $oldRev = $this->options['oldrevision'];
1259            $oldId = $oldRev->getId();
1260            $this->options['newrev'] = ( $revision->getId() !== $oldId );
1261        } else {
1262            $oldId = $revision->getParentId();
1263        }
1264
1265        if ( $oldId !== null ) {
1266            // XXX: what if $options['changed'] disagrees?
1267            // MovePage creates a dummy revision with changed = false!
1268            // We may want to explicitly distinguish between "no new revision" (null-edit)
1269            // and "new revision without new content" (dummy revision).
1270
1271            if ( $oldId === $revision->getParentId() ) {
1272                // NOTE: this may still be a NullRevision!
1273                // New revision!
1274                $this->options['changed'] = true;
1275            } elseif ( $oldId === $revision->getId() ) {
1276                // Null-edit!
1277                $this->options['changed'] = false;
1278            } else {
1279                // This indicates that calling code has given us the wrong RevisionRecord object
1280                throw new LogicException(
1281                    'The RevisionRecord mismatches old revision ID: '
1282                    . 'Old ID is ' . $oldId
1283                    . ', parent ID is ' . $revision->getParentId()
1284                    . ', revision ID is ' . $revision->getId()
1285                );
1286            }
1287        }
1288
1289        // If prepareContent() was used to generate the PST content (which is indicated by
1290        // $this->slotsUpdate being set), and this is not a null-edit, then the given
1291        // revision must have the acting user as the revision author. Otherwise, user
1292        // signatures generated by PST would mismatch the user in the revision record.
1293        if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1294            $user = $revision->getUser();
1295            if ( !$this->user->equals( $user ) ) {
1296                throw new LogicException(
1297                    'The RevisionRecord provided has a mismatching actor: expected '
1298                    . $this->user->getName()
1299                    . ', got '
1300                    . $user->getName()
1301                );
1302            }
1303        }
1304
1305        // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1306        // emulate the state of the page table before the edit, as good as we can.
1307        if ( !$this->pageState ) {
1308            $this->pageState = [
1309                'oldIsRedirect' => isset( $this->options['oldredirect'] )
1310                    && is_bool( $this->options['oldredirect'] )
1311                        ? $this->options['oldredirect']
1312                        : null,
1313                'oldCountable' => isset( $this->options['oldcountable'] )
1314                    && is_bool( $this->options['oldcountable'] )
1315                        ? $this->options['oldcountable']
1316                        : null,
1317            ];
1318
1319            if ( $this->options['changed'] ) {
1320                // The edit created a new revision
1321                $this->pageState['oldId'] = $revision->getParentId();
1322                // Old revision is null if this is a page creation
1323                $this->pageState['oldRevision'] = $this->options['oldrevision'] ?? null;
1324            } else {
1325                // This is a null-edit, so the old revision IS the new revision!
1326                $this->pageState['oldId'] = $revision->getId();
1327                $this->pageState['oldRevision'] = $revision;
1328            }
1329        }
1330
1331        // "created" is forced here
1332        $this->options['created'] = ( $this->options['created'] ||
1333                        ( $this->pageState['oldId'] === 0 ) );
1334
1335        $this->revision = $revision;
1336
1337        $this->doTransition( 'has-revision' );
1338
1339        // NOTE: in case we have a User object, don't override with a UserIdentity.
1340        // We already checked that $revision->getUser() matches $this->user;
1341        if ( !$this->user ) {
1342            $this->user = $revision->getUser( RevisionRecord::RAW );
1343        }
1344
1345        // Prune any output that depends on the revision ID.
1346        if ( $this->renderedRevision ) {
1347            $this->renderedRevision->updateRevision( $revision );
1348        } else {
1349            [ $causeAction, ] = $this->getCauseForTracing();
1350            // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1351            // NOTE: the revision is either new or current, so we can bypass audience checks.
1352            $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1353                $this->revision,
1354                null,
1355                null,
1356                [
1357                    'use-master' => $this->usePrimary(),
1358                    'audience' => RevisionRecord::RAW,
1359                    'known-revision-output' => $options['known-revision-output'] ?? null,
1360                    'causeAction' => $causeAction
1361                ]
1362            );
1363
1364            // XXX: Since we presumably are dealing with the current revision,
1365            // we could try to get the ParserOutput from the parser cache.
1366        }
1367
1368        // TODO: optionally get ParserOutput from the ParserCache here.
1369        // Move the logic used by RefreshLinksJob here!
1370    }
1371
1372    /**
1373     * @deprecated since 1.43; This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1374     * @return PreparedEdit
1375     */
1376    public function getPreparedEdit() {
1377        $this->assertPrepared( __METHOD__ );
1378
1379        $slotsUpdate = $this->getRevisionSlotsUpdate();
1380        $preparedEdit = new PreparedEdit();
1381
1382        $preparedEdit->popts = $this->getCanonicalParserOptions();
1383        $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
1384        $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
1385        $preparedEdit->newContent =
1386            $slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
1387            ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent()
1388            : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this?
1389        $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1390        $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1391        $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1392
1393        return $preparedEdit;
1394    }
1395
1396    /**
1397     * @param string $role
1398     * @param bool $generateHtml
1399     * @return ParserOutput
1400     */
1401    public function getSlotParserOutput( $role, $generateHtml = true ) {
1402        return $this->getRenderedRevision()->getSlotParserOutput(
1403            $role,
1404            [ 'generate-html' => $generateHtml ]
1405        );
1406    }
1407
1408    /**
1409     * @since 1.37
1410     * @return ParserOutput
1411     */
1412    public function getParserOutputForMetaData(): ParserOutput {
1413        return $this->getRenderedRevision()->getRevisionParserOutput( [ 'generate-html' => false ] );
1414    }
1415
1416    /**
1417     * @inheritDoc
1418     * @return ParserOutput
1419     */
1420    public function getCanonicalParserOutput(): ParserOutput {
1421        return $this->getRenderedRevision()->getRevisionParserOutput();
1422    }
1423
1424    public function getCanonicalParserOptions(): ParserOptions {
1425        return $this->getRenderedRevision()->getOptions();
1426    }
1427
1428    /**
1429     * @param bool $recursive
1430     *
1431     * @return DeferrableUpdate[]
1432     */
1433    public function getSecondaryDataUpdates( $recursive = false ) {
1434        if ( $this->isContentDeleted() ) {
1435            // This shouldn't happen, since the current content is always public,
1436            // and DataUpdates are only needed for current content.
1437            return [];
1438        }
1439
1440        $wikiPage = $this->getWikiPage();
1441        $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
1442        if ( !$wikiPage->exists() ) {
1443            // page deleted while deferring the update
1444            return [];
1445        }
1446
1447        $title = $wikiPage->getTitle();
1448        $allUpdates = [];
1449        $parserOutput = $this->shouldGenerateHTMLOnEdit() ?
1450            $this->getCanonicalParserOutput() : $this->getParserOutputForMetaData();
1451
1452        // Construct a LinksUpdate for the combined canonical output.
1453        $linksUpdate = new LinksUpdate(
1454            $title,
1455            $parserOutput,
1456            $recursive,
1457            // Redirect target may have changed if the page is or was a redirect.
1458            // (We can't check if it was definitely changed without additional queries.)
1459            $this->isRedirect() || $this->wasRedirect()
1460        );
1461        if ( $this->options['cause'] === PageRevisionUpdatedEvent::CAUSE_MOVE ) {
1462            // @phan-suppress-next-line PhanTypeMismatchArgument Oldtitle is set along with moved
1463            $linksUpdate->setMoveDetails( $this->options['oldtitle'] );
1464        }
1465
1466        $allUpdates[] = $linksUpdate;
1467        // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
1468        // info for an inherited slot may end up being removed. This is also needed
1469        // to ensure that purges are effective.
1470        $renderedRevision = $this->getRenderedRevision();
1471
1472        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1473            $slot = $this->getRawSlot( $role );
1474            $content = $slot->getContent();
1475            $handler = $content->getContentHandler();
1476
1477            $updates = $handler->getSecondaryDataUpdates(
1478                $title,
1479                $content,
1480                $role,
1481                $renderedRevision
1482            );
1483
1484            $allUpdates = array_merge( $allUpdates, $updates );
1485        }
1486
1487        // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
1488        // that time, we don't know for which slots to run deletion updates when purging a page.
1489        // We'd have to examine the entire history of the page to determine that. Perhaps there
1490        // could be a "try extra hard" mode for that case that would run a DB query to find all
1491        // roles/models ever used on the page. On the other hand, removing slots should be quite
1492        // rare, so perhaps this isn't worth the trouble.
1493
1494        // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
1495        $parentRevision = $this->getParentRevision();
1496        foreach ( $this->getRemovedSlotRoles() as $role ) {
1497            // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
1498            // For now, find the slot in the parent revision - if the slot was removed, it should
1499            // always exist in the parent revision.
1500            $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
1501            $content = $parentSlot->getContent();
1502            $handler = $content->getContentHandler();
1503
1504            $updates = $handler->getDeletionUpdates(
1505                $title,
1506                $role
1507            );
1508
1509            $allUpdates = array_merge( $allUpdates, $updates );
1510        }
1511
1512        // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
1513        $this->hookRunner->onRevisionDataUpdates( $title, $renderedRevision, $allUpdates );
1514
1515        return $allUpdates;
1516    }
1517
1518    /**
1519     * @return bool true if at least one of slots require rendering HTML on edit, false otherwise.
1520     *              This is needed for example in populating ParserCache.
1521     */
1522    private function shouldGenerateHTMLOnEdit(): bool {
1523        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1524            $slot = $this->getRawSlot( $role );
1525            $contentHandler = $this->contentHandlerFactory->getContentHandler( $slot->getModel() );
1526            if ( $contentHandler->generateHTMLOnEdit() ) {
1527                return true;
1528            }
1529        }
1530        return false;
1531    }
1532
1533    /**
1534     * Do standard updates after page edit, purge, or import.
1535     * Update links tables and other derived data.
1536     * Purges pages that depend on this page when appropriate.
1537     * With a 10% chance, triggers pruning the recent changes table.
1538     *
1539     * Further updates may be triggered by core components and extensions
1540     * that listen to the PageRevisionUpdated event. Search for method names starting
1541     * with "handlePageUpdatedEvent" to find listeners.
1542     *
1543     * @note prepareUpdate() must be called before calling this method!
1544     *
1545     * MCR migration note: this replaces WikiPage::doEditUpdates.
1546     */
1547    public function doUpdates() {
1548        $this->assertTransition( 'done' );
1549
1550        if ( $this->options['emitEvents'] ) {
1551            $this->emitEvents();
1552        }
1553
1554        // TODO: move more logic into ingress objects subscribed to PageRevisionUpdatedEvent!
1555        $event = $this->getPageRevisionUpdatedEvent();
1556
1557        if ( $this->shouldGenerateHTMLOnEdit() ) {
1558            $this->triggerParserCacheUpdate();
1559        }
1560
1561        $this->doSecondaryDataUpdates( [
1562            // T52785 do not update any other pages on dummy revisions and null edits
1563            'recursive' => $event->isEffectiveContentChange(),
1564            // Defer the getCanonicalParserOutput() call made by getSecondaryDataUpdates()
1565            'defer' => DeferredUpdates::POSTSEND
1566        ] );
1567
1568        $id = $this->getPageId();
1569        $title = $this->getTitle();
1570        $wikiPage = $this->getWikiPage();
1571
1572        if ( !$title->exists() ) {
1573            wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out" );
1574
1575            $this->doTransition( 'done' );
1576            return;
1577        }
1578
1579        DeferredUpdates::addCallableUpdate( function () use ( $event ) {
1580            if (
1581                $this->options['oldcountable'] === 'no-change' ||
1582                ( !$event->isEffectiveContentChange() && !$event->hasCause( PageRevisionUpdatedEvent::CAUSE_MOVE ) )
1583            ) {
1584                $good = 0;
1585            } elseif ( $event->isCreation() ) {
1586                $good = (int)$this->isCountable();
1587            } elseif ( $this->options['oldcountable'] !== null ) {
1588                $good = (int)$this->isCountable()
1589                    - (int)$this->options['oldcountable'];
1590            } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1591                $good = (int)$this->isCountable()
1592                    - (int)$this->pageState['oldCountable'];
1593            } else {
1594                $good = 0;
1595            }
1596            $edits = $event->isEffectiveContentChange() ? 1 : 0;
1597            $pages = $event->isCreation() ? 1 : 0;
1598
1599            DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1600                [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1601            ) );
1602        } );
1603
1604        // TODO: move onArticleCreate and onArticleEdit into a PageEventEmitter service
1605        if ( $event->isCreation() ) {
1606            // Deferred update that adds a mw-recreated tag to edits that create new pages
1607            // which have an associated deletion log entry for the specific namespace/title combination
1608            // and which are not undeletes
1609            if ( !( $event->hasCause( PageRevisionUpdatedEvent::CAUSE_UNDELETE ) ) ) {
1610                $revision = $this->revision;
1611                DeferredUpdates::addCallableUpdate( function () use ( $revision, $wikiPage ) {
1612                    $this->maybeAddRecreateChangeTag( $wikiPage, $revision->getId() );
1613                } );
1614            }
1615            WikiPage::onArticleCreate( $title, $this->isRedirect() );
1616        } elseif ( $event->isEffectiveContentChange() ) { // T52785
1617            // TODO: Check $event->isNominalContentChange() instead so we still
1618            //       trigger updates on null edits, but pass a flag to suppress
1619            //       backlink purges through queueBacklinksJobs() id
1620            //       $event->changedLatestRevisionId() returns false.
1621            WikiPage::onArticleEdit(
1622                $title,
1623                $this->revision,
1624                $this->getTouchedSlotRoles(),
1625                // Redirect target may have changed if the page is or was a redirect.
1626                // (We can't check if it was definitely changed without additional queries.)
1627                $this->isRedirect() || $this->wasRedirect()
1628            );
1629        }
1630
1631        if ( $event->hasCause( PageRevisionUpdatedEvent::CAUSE_UNDELETE ) ) {
1632            $this->mainWANObjectCache->touchCheckKey(
1633                "DerivedPageDataUpdater:restore:page:$id"
1634            );
1635        }
1636
1637        $editResult = $event->getEditResult();
1638
1639        if ( $editResult && !$editResult->isNullEdit() ) {
1640            // Cache EditResult for future use, via
1641            // RevertTagUpdateManager::approveRevertedTagForRevision().
1642            // This drives RevertedTagUpdateManager::approveRevertedTagForRevision.
1643            // It is only needed if RCPatrolling is enabled and the edit is a revert.
1644            // Skip in other cases to avoid flooding the cache, see T386217 and T388573.
1645            if ( $editResult->isRevert() && $this->useRcPatrol ) {
1646                $this->editResultCache->set(
1647                    $this->revision->getId(),
1648                    $editResult
1649                );
1650            }
1651        }
1652
1653        $this->doTransition( 'done' );
1654    }
1655
1656    /**
1657     * @internal
1658     */
1659    public function emitEvents(): void {
1660        if ( !$this->options['emitEvents'] ) {
1661            throw new LogicException( 'emitEvents was disabled on this updater' );
1662        }
1663
1664        $event = $this->getPageRevisionUpdatedEvent();
1665
1666        // don't dispatch again!
1667        $this->options['emitEvents'] = false;
1668
1669        $this->eventDispatcher->dispatch( $event, $this->loadbalancerFactory );
1670    }
1671
1672    private function getPageRevisionUpdatedEvent(): PageRevisionUpdatedEvent {
1673        if ( $this->pageUpdatedEvent ) {
1674            return $this->pageUpdatedEvent;
1675        }
1676
1677        $this->assertHasRevision( __METHOD__ );
1678
1679        $flags = array_intersect_key(
1680            $this->options,
1681            PageRevisionUpdatedEvent::DEFAULT_FLAGS
1682        );
1683
1684        $pageRecordBefore = $this->pageState['oldRecord'] ?? null;
1685        $pageRecordAfter = $this->getWikiPage()->toPageRecord();
1686
1687        $revisionBefore = $this->getOldRevision();
1688        $revisionAfter = $this->getRevision();
1689
1690        if ( $this->options['created'] ) {
1691            // Page creation. No prior state.
1692            // Force null to make sure we don't get confused during imports when
1693            // updates are triggered after importing the last revision of several.
1694            // In that case, the page and older revisions do already exist when
1695            // the DerivedPageDataUpdater is initialized, because they were
1696            // created during the import. But they didn't exist prior to the
1697            // import (based on the fact that the 'created' flag is set).
1698            $pageRecordBefore = null;
1699            $revisionBefore = null;
1700        } elseif ( !$this->options['changed'] ) {
1701            // Null edit. Should already be the same, just make sure.
1702            $pageRecordBefore = $pageRecordAfter;
1703        }
1704
1705        if ( $revisionBefore && $revisionAfter->getId() === $revisionBefore->getId() ) {
1706            // This is a null edit, flag it as a reconciliation request.
1707            $flags[ PageRevisionUpdatedEvent::FLAG_RECONCILIATION_REQUEST ] = true;
1708        }
1709
1710        if ( $pageRecordBefore === null && !$this->options['created'] ) {
1711            // If the page wasn't just created, we need the state before.
1712            // If we are not actually emitting the event, we can ignore the issue.
1713            // This is needed to support the deprecated WikiPage::doEditUpdates()
1714            // method. Once that is gone, we can remove this conditional.
1715            if ( $this->options['emitEvents'] ) {
1716                throw new LogicException( 'Missing page state before update' );
1717            }
1718        }
1719
1720        /** @var UserIdentity $performer */
1721        $performer = $this->options['triggeringUser'] ?? $this->user;
1722        '@phan-var UserIdentity $performer';
1723
1724        $this->pageUpdatedEvent = new PageRevisionUpdatedEvent(
1725            $this->options['cause'] ?? PageUpdateCauses::CAUSE_EDIT,
1726            $pageRecordBefore,
1727            $pageRecordAfter,
1728            $revisionBefore,
1729            $revisionAfter,
1730            $this->getRevisionSlotsUpdate(),
1731            $this->options['editResult'] ?? null,
1732            $performer,
1733            $this->options['tags'] ?? [],
1734            $flags,
1735            $this->options['rcPatrolStatus'] ?? 0,
1736        );
1737
1738        return $this->pageUpdatedEvent;
1739    }
1740
1741    private function triggerParserCacheUpdate() {
1742        $this->assertHasRevision( __METHOD__ );
1743
1744        $userParserOptions = ParserOptions::newFromUser( $this->user );
1745
1746        // Decide whether to save the final canonical parser output based on the fact that
1747        // users are typically redirected to viewing pages right after they edit those pages.
1748        // Due to vary-revision-id, getting/saving that output here might require a reparse.
1749        if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
1750            // Whether getting the final output requires a reparse or not, the user will
1751            // need canonical output anyway, since that is what their parser options use.
1752            // A reparse now at least has the benefit of various warm process caches.
1753            $this->doParserCacheUpdate();
1754        } else {
1755            // If the user does not have canonical parse options, then don't risk another parse
1756            // to make output they cannot use on the page refresh that typically occurs after
1757            // editing. Doing the parser output save post-send will still benefit *other* users.
1758            DeferredUpdates::addCallableUpdate( function () {
1759                $this->doParserCacheUpdate();
1760            } );
1761        }
1762    }
1763
1764    /**
1765     * Checks deletion logs for the specific article title and namespace combination
1766     * if a deletion log exists, we can assume this is a new page recreation and are tagging it with `mw-recreated`.
1767     * This does not consider deletions that were suppressed and therefore will not tag those.
1768     *
1769     * @param WikiPage $wikiPage
1770     * @param int $revisionId
1771     */
1772    private function maybeAddRecreateChangeTag( WikiPage $wikiPage, int $revisionId ) {
1773        if ( $this->loadbalancerFactory->getReplicaDatabase()->newSelectQueryBuilder()
1774                ->select( [ '1' ] )
1775                ->from( 'logging' )
1776                ->where( [
1777                    'log_type' => 'delete',
1778                    'log_title' => $wikiPage->getTitle()->getDBkey(),
1779                    'log_namespace' => $wikiPage->getNamespace(),
1780                ] )->caller( __METHOD__ )->limit( 1 )->fetchField() ) {
1781            $this->changeTagsStore->addTags(
1782                [ ChangeTags::TAG_RECREATE ],
1783                null,
1784                $revisionId );
1785        }
1786    }
1787
1788    /**
1789     * Do secondary data updates (e.g. updating link tables) or schedule them as deferred updates
1790     *
1791     * @note This does not update the parser cache. Use doParserCacheUpdate() for that.
1792     * @note Application logic should use Wikipage::doSecondaryDataUpdates instead.
1793     *
1794     * @param array $options
1795     *   - recursive: make the update recursive, i.e. also update pages which transclude the
1796     *     current page or otherwise depend on it (default: false)
1797     *   - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1798     *     for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1799     *   - freshness: used with 'defer'; forces an update if the last update was before the given timestamp,
1800     *     even if the page and its dependencies didn't change since then (TS_MW; default: false)
1801     * @since 1.32
1802     */
1803    public function doSecondaryDataUpdates( array $options = [] ) {
1804        $this->assertHasRevision( __METHOD__ );
1805        $options += [ 'recursive' => false, 'defer' => false, 'freshness' => false ];
1806        $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1807        if ( !in_array( $options['defer'], $deferValues, true ) ) {
1808            throw new InvalidArgumentException( 'Invalid value for defer: ' . $options['defer'] );
1809        }
1810
1811        $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1812        [ $causeAction, $causeAgent ] = $this->getCauseForTracing();
1813        if ( isset( $options['known-revision-output'] ) ) {
1814            $this->getRenderedRevision()->setRevisionParserOutput( $options['known-revision-output'] );
1815        }
1816
1817        // Bundle all of the data updates into a single deferred update wrapper so that
1818        // any failure will cause at most one refreshLinks job to be enqueued by
1819        // DeferredUpdates::doUpdates(). This is hard to do when there are many separate
1820        // updates that are not defined as being related.
1821        $update = new RefreshSecondaryDataUpdate(
1822            $this->loadbalancerFactory,
1823            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Already checked
1824            $triggeringUser,
1825            $this->wikiPage,
1826            $this->revision,
1827            $this,
1828            [ 'recursive' => $options['recursive'], 'freshness' => $options['freshness'] ]
1829        );
1830        $update->setCause( $causeAction, $causeAgent );
1831
1832        if ( $options['defer'] === false ) {
1833            DeferredUpdates::attemptUpdate( $update );
1834        } else {
1835            DeferredUpdates::addUpdate( $update, $options['defer'] );
1836        }
1837    }
1838
1839    /**
1840     * Causes parser cache entries to be updated.
1841     *
1842     * @note This does not update links tables. Use doSecondaryDataUpdates() for that.
1843     * @note Application logic should use Wikipage::updateParserCache instead.
1844     */
1845    public function doParserCacheUpdate() {
1846        $this->assertHasRevision( __METHOD__ );
1847
1848        $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1849
1850        // NOTE: this may trigger the first parsing of the new content after an edit (when not
1851        // using pre-generated stashed output).
1852        // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1853        // to be performed post-send. The client could already follow a HTTP redirect to the
1854        // page view, but would then have to wait for a response until rendering is complete.
1855        $output = $this->getCanonicalParserOutput();
1856
1857        // Save it to the parser cache. Use the revision timestamp in the case of a
1858        // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1859        // unnecessary reparse.
1860        $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp()
1861            : $output->getCacheTime();
1862        $this->parserCache->save(
1863            $output, $wikiPage, $this->getCanonicalParserOptions(),
1864            $timestamp, $this->revision->getId()
1865        );
1866
1867        // If we enable cache warming with parsoid outputs, let's do it at the same
1868        // time we're populating the parser cache with pre-generated HTML.
1869        // Use OPT_FORCE_PARSE to avoid a useless cache lookup.
1870        if ( $this->warmParsoidParserCache ) {
1871            $cacheWarmingParams = $this->getCauseForTracing();
1872            $cacheWarmingParams['options'] = ParserOutputAccess::OPT_FORCE_PARSE;
1873
1874            $this->jobQueueGroup->lazyPush(
1875                ParsoidCachePrewarmJob::newSpec(
1876                    $this->revision->getId(),
1877                    $wikiPage->toPageRecord(),
1878                    $cacheWarmingParams
1879                )
1880            );
1881        }
1882    }
1883
1884}