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