CentralIdLookup: Add @since to factoryNonLocal()
[mediawiki.git] / includes / Storage / DerivedPageDataUpdater.php
blobab7bc3da91d2f0ac143f982a30ebaeeedff3fbc6
1 <?php
2 /**
3 * A handle for managing updates for derived page data on edit, import, purge, etc.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
20 * @file
23 namespace MediaWiki\Storage;
25 use CategoryMembershipChangeJob;
26 use Content;
27 use ContentHandler;
28 use DeferrableUpdate;
29 use DeferredUpdates;
30 use IDBAccessObject;
31 use InvalidArgumentException;
32 use JobQueueGroup;
33 use Language;
34 use LinksDeletionUpdate;
35 use LinksUpdate;
36 use LogicException;
37 use MediaWiki\Content\IContentHandlerFactory;
38 use MediaWiki\Edit\PreparedEdit;
39 use MediaWiki\HookContainer\HookContainer;
40 use MediaWiki\HookContainer\HookRunner;
41 use MediaWiki\MediaWikiServices;
42 use MediaWiki\Revision\MutableRevisionRecord;
43 use MediaWiki\Revision\RenderedRevision;
44 use MediaWiki\Revision\RevisionRecord;
45 use MediaWiki\Revision\RevisionRenderer;
46 use MediaWiki\Revision\RevisionSlots;
47 use MediaWiki\Revision\RevisionStore;
48 use MediaWiki\Revision\SlotRecord;
49 use MediaWiki\Revision\SlotRoleRegistry;
50 use MediaWiki\User\UserIdentity;
51 use MessageCache;
52 use MWTimestamp;
53 use MWUnknownContentModelException;
54 use ParserCache;
55 use ParserOptions;
56 use ParserOutput;
57 use Psr\Log\LoggerAwareInterface;
58 use Psr\Log\LoggerInterface;
59 use Psr\Log\NullLogger;
60 use RecentChangesUpdateJob;
61 use RefreshSecondaryDataUpdate;
62 use ResourceLoaderWikiModule;
63 use RevertedTagUpdateJob;
64 use Revision;
65 use SearchUpdate;
66 use SiteStatsUpdate;
67 use Title;
68 use User;
69 use Wikimedia\Assert\Assert;
70 use Wikimedia\Rdbms\ILBFactory;
71 use WikiPage;
73 /**
74 * A handle for managing updates for derived page data on edit, import, purge, etc.
76 * @note Avoid direct usage of DerivedPageDataUpdater.
78 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
79 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
80 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
81 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
82 * Content::getSecondaryDataUpdates().
84 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
85 * and re-used by callback code over the course of an update operation. It's a stepping stone
86 * on the way to a more complete refactoring of WikiPage.
88 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
89 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
90 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
91 * require prepareContent or prepareUpdate to have been called first, to initialize the
92 * DerivedPageDataUpdater.
94 * @see docs/pageupdater.md for more information.
96 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
97 * of PreparedEdit.
99 * @internal
101 * @since 1.32
102 * @ingroup Page
104 class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface {
107 * @var UserIdentity|null
109 private $user = null;
112 * @var WikiPage
114 private $wikiPage;
117 * @var ParserCache
119 private $parserCache;
122 * @var RevisionStore
124 private $revisionStore;
127 * @var Language
129 private $contLang;
132 * @var JobQueueGroup
134 private $jobQueueGroup;
137 * @var MessageCache
139 private $messageCache;
142 * @var ILBFactory
144 private $loadbalancerFactory;
147 * @var HookRunner
149 private $hookRunner;
152 * @var LoggerInterface
154 private $logger;
157 * @var string see $wgArticleCountMethod
159 private $articleCountMethod;
162 * @var boolean see $wgRCWatchCategoryMembership
164 private $rcWatchCategoryMembership = false;
167 * Stores (most of) the $options parameter of prepareUpdate().
168 * @see prepareUpdate()
170 * @codingStandardsIgnoreStart
171 * @phan-var array{changed:bool,created:bool,moved:bool,restored:bool,oldrevision:null|RevisionRecord,triggeringUser:null|UserIdentity,oldredirect:bool|null|string,oldcountable:bool|null|string,causeAction:null|string,causeAgent:null|string,editResult:null|EditResult,approved:bool}
172 * @codingStandardsIgnoreEnd
174 private $options = [
175 'changed' => true,
176 // newrev is true if prepareUpdate is handling the creation of a new revision,
177 // as opposed to a null edit or a forced update.
178 'newrev' => false,
179 'created' => false,
180 'moved' => false,
181 'restored' => false,
182 'oldrevision' => null,
183 'oldcountable' => null,
184 'oldredirect' => null,
185 'triggeringUser' => null,
186 // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
187 // to make the life of prepareUpdate() callers easier.
188 'causeAction' => null,
189 'causeAgent' => null,
190 'editResult' => null,
191 'approved' => false,
195 * The state of the relevant row in page table before the edit.
196 * This is determined by the first call to grabCurrentRevision, prepareContent,
197 * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
198 * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
199 * attempt to emulate the state of the page table before the edit.
201 * Contains the following fields:
202 * - oldRevision (RevisionRecord|null): the revision that was current before the change
203 * associated with this update. Might not be set, use getParentRevision().
204 * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
205 * was about creating a new page); null if not known (that should not happen).
206 * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
207 * can be null; use wasRedirect() instead of direct access.
208 * - oldCountable (bool|null): whether the page was countable before the change (or null
209 * if we don't have that information)
211 * @var array
213 private $pageState = null;
216 * @var RevisionSlotsUpdate|null
218 private $slotsUpdate = null;
221 * @var RevisionRecord|null
223 private $parentRevision = null;
226 * @var RevisionRecord|null
228 private $revision = null;
231 * @var RenderedRevision
233 private $renderedRevision = null;
236 * @var RevisionRenderer
238 private $revisionRenderer;
240 /** @var SlotRoleRegistry */
241 private $slotRoleRegistry;
244 * A stage identifier for managing the life cycle of this instance.
245 * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
247 * @see docs/pageupdater.md for documentation of the life cycle.
249 * @var string
251 private $stage = 'new';
254 * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
256 * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
257 * and constants are also overkill...
259 * @see docs/pageupdater.md for documentation of the life cycle.
261 * @var array[]
263 private const TRANSITIONS = [
264 'new' => [
265 'new' => true,
266 'knows-current' => true,
267 'has-content' => true,
268 'has-revision' => true,
270 'knows-current' => [
271 'knows-current' => true,
272 'has-content' => true,
273 'has-revision' => true,
275 'has-content' => [
276 'has-content' => true,
277 'has-revision' => true,
279 'has-revision' => [
280 'has-revision' => true,
281 'done' => true,
285 /** @var IContentHandlerFactory */
286 private $contentHandlerFactory;
288 /** @var EditResultCache */
289 private $editResultCache;
292 * @param WikiPage $wikiPage ,
293 * @param RevisionStore $revisionStore
294 * @param RevisionRenderer $revisionRenderer
295 * @param SlotRoleRegistry $slotRoleRegistry
296 * @param ParserCache $parserCache
297 * @param JobQueueGroup $jobQueueGroup
298 * @param MessageCache $messageCache
299 * @param Language $contLang
300 * @param ILBFactory $loadbalancerFactory
301 * @param IContentHandlerFactory $contentHandlerFactory
302 * @param HookContainer $hookContainer
303 * @param EditResultCache $editResultCache
305 public function __construct(
306 WikiPage $wikiPage,
307 RevisionStore $revisionStore,
308 RevisionRenderer $revisionRenderer,
309 SlotRoleRegistry $slotRoleRegistry,
310 ParserCache $parserCache,
311 JobQueueGroup $jobQueueGroup,
312 MessageCache $messageCache,
313 Language $contLang,
314 ILBFactory $loadbalancerFactory,
315 IContentHandlerFactory $contentHandlerFactory,
316 HookContainer $hookContainer,
317 EditResultCache $editResultCache
319 $this->wikiPage = $wikiPage;
321 $this->parserCache = $parserCache;
322 $this->revisionStore = $revisionStore;
323 $this->revisionRenderer = $revisionRenderer;
324 $this->slotRoleRegistry = $slotRoleRegistry;
325 $this->jobQueueGroup = $jobQueueGroup;
326 $this->messageCache = $messageCache;
327 $this->contLang = $contLang;
328 // XXX only needed for waiting for replicas to catch up; there should be a narrower
329 // interface for that.
330 $this->loadbalancerFactory = $loadbalancerFactory;
331 $this->contentHandlerFactory = $contentHandlerFactory;
332 $this->hookRunner = new HookRunner( $hookContainer );
333 $this->editResultCache = $editResultCache;
335 $this->logger = new NullLogger();
338 public function setLogger( LoggerInterface $logger ) {
339 $this->logger = $logger;
343 * Transition function for managing the life cycle of this instances.
345 * @see docs/pageupdater.md for documentation of the life cycle.
347 * @param string $newStage the new stage
348 * @return string the previous stage
350 * @throws LogicException If a transition to the given stage is not possible in the current
351 * stage.
353 private function doTransition( $newStage ) {
354 $this->assertTransition( $newStage );
356 $oldStage = $this->stage;
357 $this->stage = $newStage;
359 return $oldStage;
363 * Asserts that a transition to the given stage is possible, without performing it.
365 * @see docs/pageupdater.md for documentation of the life cycle.
367 * @param string $newStage the new stage
369 * @throws LogicException If this instance is not in the expected stage
371 private function assertTransition( $newStage ) {
372 if ( empty( self::TRANSITIONS[$this->stage][$newStage] ) ) {
373 throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
378 * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
379 * the given revision.
381 * @param UserIdentity|null $user The user creating the revision in question
382 * @param RevisionRecord|null $revision New revision (after save, if already saved)
383 * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
384 * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
386 * @return bool
388 public function isReusableFor(
389 UserIdentity $user = null,
390 RevisionRecord $revision = null,
391 RevisionSlotsUpdate $slotsUpdate = null,
392 $parentId = null
394 if ( $revision
395 && $parentId
396 && $revision->getParentId() !== $parentId
398 throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
401 // NOTE: For null revisions, $user may be different from $this->revision->getUser
402 // and also from $revision->getUser.
403 // But $user should always match $this->user.
404 if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
405 return false;
408 if ( $revision && $this->revision && $this->revision->getId()
409 && $this->revision->getId() !== $revision->getId()
411 return false;
414 if ( $this->pageState
415 && $revision
416 && $revision->getParentId() !== null
417 && $this->pageState['oldId'] !== $revision->getParentId()
419 return false;
422 if ( $this->pageState
423 && $parentId !== null
424 && $this->pageState['oldId'] !== $parentId
426 return false;
429 // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
430 if ( $this->slotsUpdate
431 && $slotsUpdate
432 && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
434 return false;
437 if ( $revision
438 && $this->revision
439 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
441 return false;
444 return true;
448 * @param string $articleCountMethod "any" or "link".
449 * @see $wgArticleCountMethod
451 public function setArticleCountMethod( $articleCountMethod ) {
452 $this->articleCountMethod = $articleCountMethod;
456 * @param bool $rcWatchCategoryMembership
457 * @see $wgRCWatchCategoryMembership
459 public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
460 $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
464 * @return Title
466 private function getTitle() {
467 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
468 return $this->wikiPage->getTitle();
472 * @return WikiPage
474 private function getWikiPage() {
475 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
476 return $this->wikiPage;
480 * Determines whether the page being edited already existed.
481 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
483 * @return bool
484 * @throws LogicException if called before grabCurrentRevision
486 public function pageExisted() {
487 $this->assertHasPageState( __METHOD__ );
489 return $this->pageState['oldId'] > 0;
493 * Returns the parent revision of the new revision wrapped by this update.
494 * If the update is a null-edit, this will return the parent of the current (and new) revision.
495 * This will return null if the revision wrapped by this update created the page.
496 * Only defined after calling prepareContent() or prepareUpdate()!
498 * @return RevisionRecord|null the parent revision of the new revision, or null if
499 * the update created the page.
501 private function getParentRevision() {
502 $this->assertPrepared( __METHOD__ );
504 if ( $this->parentRevision ) {
505 return $this->parentRevision;
508 if ( !$this->pageState['oldId'] ) {
509 // If there was no current revision, there is no parent revision,
510 // since the page didn't exist.
511 return null;
514 $oldId = $this->revision->getParentId();
515 $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
516 $this->parentRevision = $oldId
517 ? $this->revisionStore->getRevisionById( $oldId, $flags )
518 : null;
520 return $this->parentRevision;
524 * Returns the revision that was the page's current revision when grabCurrentRevision()
525 * was first called.
527 * During an edit, that revision will act as the logical parent of the new revision.
529 * Some updates are performed based on the difference between the database state at the
530 * moment this method is first called, and the state after the edit.
532 * @see docs/pageupdater.md for more information on when thie method can and should be called.
534 * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
535 * to avoid confusion, since the page's current revision is then the new revision after
536 * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
537 * Use getParentRevision() instead to access the revision that is the parent of the
538 * new revision.
540 * @return RevisionRecord|null the page's current revision, or null if the page does not
541 * yet exist.
543 public function grabCurrentRevision() {
544 if ( $this->pageState ) {
545 return $this->pageState['oldRevision'];
548 $this->assertTransition( 'knows-current' );
550 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
551 $wikiPage = $this->getWikiPage();
553 // Do not call WikiPage::clear(), since the caller may already have caused page data
554 // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
555 $wikiPage->loadPageData( self::READ_LATEST );
556 $current = $wikiPage->getRevisionRecord();
558 $this->pageState = [
559 'oldRevision' => $current,
560 'oldId' => $current ? $current->getId() : 0,
561 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
562 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
565 $this->doTransition( 'knows-current' );
567 return $this->pageState['oldRevision'];
571 * Whether prepareUpdate() or prepareContent() have been called on this instance.
573 * @return bool
575 public function isContentPrepared() {
576 return $this->revision !== null;
580 * Whether prepareUpdate() has been called on this instance.
582 * @note will also return null in case of a null-edit!
584 * @return bool
586 public function isUpdatePrepared() {
587 return $this->revision !== null && $this->revision->getId() !== null;
591 * @return int
593 private function getPageId() {
594 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
595 return $this->wikiPage->getId();
599 * Whether the content is deleted and thus not visible to the public.
601 * @return bool
603 public function isContentDeleted() {
604 if ( $this->revision ) {
605 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
606 } else {
607 // If the content has not been saved yet, it cannot have been deleted yet.
608 return false;
613 * Returns the slot, modified or inherited, after PST, with no audience checks applied.
615 * @param string $role slot role name
617 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
618 * parent revision.
619 * @return SlotRecord
621 public function getRawSlot( $role ) {
622 return $this->getSlots()->getSlot( $role );
626 * Returns the content of the given slot, with no audience checks.
628 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
629 * parent revision.
630 * @param string $role slot role name
631 * @return Content
633 public function getRawContent( $role ) {
634 return $this->getRawSlot( $role )->getContent();
638 * Returns the content model of the given slot
640 * @param string $role slot role name
641 * @return string
643 private function getContentModel( $role ) {
644 return $this->getRawSlot( $role )->getModel();
648 * @param string $role slot role name
649 * @return ContentHandler
650 * @throws MWUnknownContentModelException
652 private function getContentHandler( $role ): ContentHandler {
653 return $this->contentHandlerFactory
654 ->getContentHandler( $this->getContentModel( $role ) );
657 private function useMaster() {
658 // TODO: can we just set a flag to true in prepareContent()?
659 return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
663 * @return bool
665 public function isCountable() {
666 // NOTE: Keep in sync with WikiPage::isCountable.
668 if ( !$this->getTitle()->isContentPage() ) {
669 return false;
672 if ( $this->isContentDeleted() ) {
673 // This should be irrelevant: countability only applies to the current revision,
674 // and the current revision is never suppressed.
675 return false;
678 if ( $this->isRedirect() ) {
679 return false;
682 $hasLinks = null;
684 if ( $this->articleCountMethod === 'link' ) {
685 // NOTE: it would be more appropriate to determine for each slot separately
686 // whether it has links, and use that information with that slot's
687 // isCountable() method. However, that would break parity with
688 // WikiPage::isCountable, which uses the pagelinks table to determine
689 // whether the current revision has links.
690 $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
693 foreach ( $this->getSlots()->getSlotRoles() as $role ) {
694 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
695 if ( $roleHandler->supportsArticleCount() ) {
696 $content = $this->getRawContent( $role );
698 if ( $content->isCountable( $hasLinks ) ) {
699 return true;
704 return false;
708 * @return bool
710 public function isRedirect() {
711 // NOTE: main slot determines redirect status
712 // TODO: MCR: this should be controlled by a PageTypeHandler
713 $mainContent = $this->getRawContent( SlotRecord::MAIN );
715 return $mainContent->isRedirect();
719 * @param RevisionRecord $rev
721 * @return bool
723 private function revisionIsRedirect( RevisionRecord $rev ) {
724 // NOTE: main slot determines redirect status
725 $mainContent = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
727 return $mainContent->isRedirect();
731 * Prepare updates based on an update which has not yet been saved.
733 * This may be used to create derived data that is needed when creating a new revision;
734 * particularly, this makes available the slots of the new revision via the getSlots()
735 * method, after applying PST and slot inheritance.
737 * The derived data prepared for revision creation may then later be re-used by doUpdates(),
738 * without the need to re-calculate.
740 * @see docs/pageupdater.md for more information on when thie method can and should be called.
742 * @note Calling this method more than once with the same $slotsUpdate
743 * has no effect. Calling this method multiple times with different content will cause
744 * an exception.
746 * @note Calling this method after prepareUpdate() has been called will cause an exception.
748 * @param User $user The user to act as context for pre-save transformation (PST).
749 * Type hint should be reduced to UserIdentity at some point.
750 * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
751 * by this edit, before PST.
752 * @param bool $useStash Whether to use stashed ParserOutput
754 public function prepareContent(
755 User $user,
756 RevisionSlotsUpdate $slotsUpdate,
757 $useStash = true
759 if ( $this->slotsUpdate ) {
760 if ( !$this->user ) {
761 throw new LogicException(
762 'Unexpected state: $this->slotsUpdate was initialized, '
763 . 'but $this->user was not.'
767 if ( $this->user->getName() !== $user->getName() ) {
768 throw new LogicException( 'Can\'t call prepareContent() again for different user! '
769 . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
773 if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
774 throw new LogicException(
775 'Can\'t call prepareContent() again with different slot content!'
779 return; // prepareContent() already done, nothing to do
782 $this->assertTransition( 'has-content' );
784 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
785 $title = $this->getTitle();
787 $parentRevision = $this->grabCurrentRevision();
789 // The edit may have already been prepared via api.php?action=stashedit
790 $stashedEdit = false;
792 // TODO: MCR: allow output for all slots to be stashed.
793 if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
794 $editStash = MediaWikiServices::getInstance()->getPageEditStash();
795 $stashedEdit = $editStash->checkCache(
796 $title,
797 $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
798 User::newFromIdentity( $user )
802 $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
803 $this->hookRunner->onArticlePrepareTextForEdit( $wikiPage, $userPopts );
805 $this->user = $user;
806 $this->slotsUpdate = $slotsUpdate;
808 if ( $parentRevision ) {
809 $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
810 } else {
811 $this->revision = new MutableRevisionRecord( $title );
814 // NOTE: user and timestamp must be set, so they can be used for
815 // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
816 $this->revision->setTimestamp( MWTimestamp::now( TS_MW ) );
817 $this->revision->setUser( $user );
819 // Set up ParserOptions to operate on the new revision
820 $oldCallback = $userPopts->getCurrentRevisionRecordCallback();
821 $userPopts->setCurrentRevisionRecordCallback(
822 function ( Title $parserTitle, $parser = null ) use ( $title, $oldCallback ) {
823 if ( $parserTitle->equals( $title ) ) {
824 return $this->revision;
825 } else {
826 return call_user_func( $oldCallback, $parserTitle, $parser );
831 $pstContentSlots = $this->revision->getSlots();
833 foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
834 $slot = $slotsUpdate->getModifiedSlot( $role );
836 if ( $slot->isInherited() ) {
837 // No PST for inherited slots! Note that "modified" slots may still be inherited
838 // from an earlier version, e.g. for rollbacks.
839 $pstSlot = $slot;
840 } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
841 // TODO: MCR: allow PST content for all slots to be stashed.
842 $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
843 } else {
844 $content = $slot->getContent();
845 $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
846 $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
849 $pstContentSlots->setSlot( $pstSlot );
852 foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
853 $pstContentSlots->removeSlot( $role );
856 $this->options['created'] = ( $parentRevision === null );
857 $this->options['changed'] = ( $parentRevision === null
858 || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
860 $this->doTransition( 'has-content' );
862 if ( !$this->options['changed'] ) {
863 // null-edit!
865 // TODO: move this into MutableRevisionRecord
866 // TODO: This needs to behave differently for a forced dummy edit!
867 $this->revision->setId( $parentRevision->getId() );
868 $this->revision->setTimestamp( $parentRevision->getTimestamp() );
869 $this->revision->setPageId( $parentRevision->getPageId() );
870 $this->revision->setParentId( $parentRevision->getParentId() );
871 $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
872 $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
873 $this->revision->setMinorEdit( $parentRevision->isMinor() );
874 $this->revision->setVisibility( $parentRevision->getVisibility() );
876 // prepareUpdate() is redundant for null-edits
877 $this->doTransition( 'has-revision' );
878 } else {
879 $this->parentRevision = $parentRevision;
882 $renderHints = [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ];
884 if ( $stashedEdit ) {
885 /** @var ParserOutput $output */
886 $output = $stashedEdit->output;
887 // TODO: this should happen when stashing the ParserOutput, not now!
888 $output->setCacheTime( $stashedEdit->timestamp );
890 $renderHints['known-revision-output'] = $output;
892 $this->logger->debug( __METHOD__ . ': using stashed edit output...' );
895 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
896 // NOTE: the revision is either new or current, so we can bypass audience checks.
897 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
898 $this->revision,
899 null,
900 null,
901 $renderHints
906 * Returns the update's target revision - that is, the revision that will be the current
907 * revision after the update.
909 * @note Callers must treat the returned RevisionRecord's content as immutable, even
910 * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
911 * returned from here, such as the user or the comment, may be changed, but may not
912 * be reflected in ParserOutput until after prepareUpdate() has been called.
914 * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
915 * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
916 * for that purpose instead!
918 * @return RevisionRecord
920 public function getRevision() {
921 $this->assertPrepared( __METHOD__ );
922 return $this->revision;
926 * @return RenderedRevision
928 public function getRenderedRevision() {
929 $this->assertPrepared( __METHOD__ );
931 return $this->renderedRevision;
934 private function assertHasPageState( $method ) {
935 if ( !$this->pageState ) {
936 throw new LogicException(
937 'Must call grabCurrentRevision() or prepareContent() '
938 . 'or prepareUpdate() before calling ' . $method
943 private function assertPrepared( $method ) {
944 if ( !$this->revision ) {
945 throw new LogicException(
946 'Must call prepareContent() or prepareUpdate() before calling ' . $method
951 private function assertHasRevision( $method ) {
952 if ( !$this->revision->getId() ) {
953 throw new LogicException(
954 'Must call prepareUpdate() before calling ' . $method
960 * Whether the edit creates the page.
962 * @return bool
964 public function isCreation() {
965 $this->assertPrepared( __METHOD__ );
966 return $this->options['created'];
970 * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
972 * @warning at present, "null-revisions" that do not change content but do have a revision
973 * record would return false after prepareContent(), but true after prepareUpdate()!
974 * This should probably be fixed.
976 * @return bool
978 public function isChange() {
979 $this->assertPrepared( __METHOD__ );
980 return $this->options['changed'];
984 * Whether the page was a redirect before the edit.
986 * @return bool
988 public function wasRedirect() {
989 $this->assertHasPageState( __METHOD__ );
991 if ( $this->pageState['oldIsRedirect'] === null ) {
992 /** @var RevisionRecord $rev */
993 $rev = $this->pageState['oldRevision'];
994 if ( $rev ) {
995 $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
996 } else {
997 $this->pageState['oldIsRedirect'] = false;
1001 return $this->pageState['oldIsRedirect'];
1005 * Returns the slots of the target revision, after PST.
1007 * @note Callers must treat the returned RevisionSlots instance as immutable, even
1008 * if it is a MutableRevisionSlots instance.
1010 * @return RevisionSlots
1012 public function getSlots() {
1013 $this->assertPrepared( __METHOD__ );
1014 return $this->revision->getSlots();
1018 * Returns the RevisionSlotsUpdate for this updater.
1020 * @return RevisionSlotsUpdate
1022 private function getRevisionSlotsUpdate() {
1023 $this->assertPrepared( __METHOD__ );
1025 if ( !$this->slotsUpdate ) {
1026 $old = $this->getParentRevision();
1027 $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
1028 $this->revision->getSlots(),
1029 $old ? $old->getSlots() : null
1032 return $this->slotsUpdate;
1036 * Returns the role names of the slots touched by the new revision,
1037 * including removed roles.
1039 * @return string[]
1041 public function getTouchedSlotRoles() {
1042 return $this->getRevisionSlotsUpdate()->getTouchedRoles();
1046 * Returns the role names of the slots modified by the new revision,
1047 * not including removed roles.
1049 * @return string[]
1051 public function getModifiedSlotRoles() {
1052 return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1056 * Returns the role names of the slots removed by the new revision.
1058 * @return string[]
1060 public function getRemovedSlotRoles() {
1061 return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1065 * Prepare derived data updates targeting the given Revision.
1067 * Calling this method requires the given revision to be present in the database.
1068 * This may be right after a new revision has been created, or when re-generating
1069 * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1070 * script.
1072 * @see docs/pageupdater.md for more information on when thie method can and should be called.
1074 * @note Calling this method more than once with the same revision has no effect.
1075 * $options are only used for the first call. Calling this method multiple times with
1076 * different revisions will cause an exception.
1078 * @note If grabCurrentRevision() (or prepareContent()) has been called before
1079 * calling this method, $revision->getParentRevision() has to refer to the revision that
1080 * was the current revision at the time grabCurrentRevision() was called.
1082 * @param RevisionRecord $revision
1083 * @param array $options Array of options, following indexes are used:
1084 * - changed: bool, whether the revision changed the content (default true)
1085 * - created: bool, whether the revision created the page (default false)
1086 * - moved: bool, whether the page was moved (default false)
1087 * - restored: bool, whether the page was undeleted (default false)
1088 * - oldrevision: RevisionRecord object for the pre-update revision (default null)
1089 * can also be a Revision object, which is deprecated since 1.35
1090 * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1091 * user who created the revision)
1092 * - oldredirect: bool, null, or string 'no-change' (default null):
1093 * - bool: whether the page was counted as a redirect before that
1094 * revision, only used in changed is true and created is false
1095 * - null or 'no-change': don't update the redirect status.
1096 * - oldcountable: bool, null, or string 'no-change' (default null):
1097 * - bool: whether the page was counted as an article before that
1098 * revision, only used in changed is true and created is false
1099 * - null: if created is false, don't update the article count; if created
1100 * is true, do update the article count
1101 * - 'no-change': don't update the article count, ever
1102 * When set to null, pageState['oldCountable'] will be used instead if available.
1103 * - causeAction: an arbitrary string identifying the reason for the update.
1104 * See DataUpdate::getCauseAction(). (default 'unknown')
1105 * - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1106 * (string, default 'unknown')
1107 * - known-revision-output: a combined canonical ParserOutput for the revision, perhaps
1108 * from some cache. The caller is responsible for ensuring that the ParserOutput indeed
1109 * matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
1110 * for the time until caches have been changed to store RenderedRevision states instead
1111 * of ParserOutput objects. (default: null) (since 1.33)
1112 * - editResult: EditResult object created during the update. Required to perform reverted
1113 * tag update using RevertedTagUpdateJob. (default: null) (since 1.36)
1114 * - approved: whether the edit is somehow "approved" and the RevertedTagUpdateJob should
1115 * be scheduled right away. Required only if EditResult::isRevert() is true. (boolean,
1116 * default: false) (since 1.36)
1118 public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1119 if ( isset( $options['oldrevision'] ) && $options['oldrevision'] instanceof Revision ) {
1120 wfDeprecated(
1121 __METHOD__ . ' with the `oldrevision` option being a ' .
1122 'Revision object',
1123 '1.35'
1125 $options['oldrevision'] = $options['oldrevision']->getRevisionRecord();
1127 Assert::parameter(
1128 !isset( $options['oldrevision'] )
1129 || $options['oldrevision'] instanceof RevisionRecord,
1130 '$options["oldrevision"]',
1131 'must be a RevisionRecord (or Revision)'
1133 Assert::parameter(
1134 !isset( $options['triggeringUser'] )
1135 || $options['triggeringUser'] instanceof UserIdentity,
1136 '$options["triggeringUser"]',
1137 'must be a UserIdentity'
1139 Assert::parameter(
1140 !isset( $options['editResult'] )
1141 || $options['editResult'] instanceof EditResult,
1142 '$options["editResult"]',
1143 'must be an EditResult'
1146 if ( !$revision->getId() ) {
1147 throw new InvalidArgumentException(
1148 'Revision must have an ID set for it to be used with prepareUpdate()!'
1152 if ( $this->revision && $this->revision->getId() ) {
1153 if ( $this->revision->getId() === $revision->getId() ) {
1154 return; // nothing to do!
1155 } else {
1156 throw new LogicException(
1157 'Trying to re-use DerivedPageDataUpdater with revision '
1158 . $revision->getId()
1159 . ', but it\'s already bound to revision '
1160 . $this->revision->getId()
1165 if ( $this->revision
1166 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1168 throw new LogicException(
1169 'The Revision provided has mismatching content!'
1173 // Override fields defined in $this->options with values from $options.
1174 $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1176 if ( $this->revision ) {
1177 $oldId = $this->pageState['oldId'] ?? 0;
1178 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1179 } elseif ( isset( $this->options['oldrevision'] ) ) {
1180 /** @var RevisionRecord $oldRev */
1181 $oldRev = $this->options['oldrevision'];
1182 $oldId = $oldRev->getId();
1183 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1184 } else {
1185 $oldId = $revision->getParentId();
1188 if ( $oldId !== null ) {
1189 // XXX: what if $options['changed'] disagrees?
1190 // MovePage creates a dummy revision with changed = false!
1191 // We may want to explicitly distinguish between "no new revision" (null-edit)
1192 // and "new revision without new content" (dummy revision).
1194 if ( $oldId === $revision->getParentId() ) {
1195 // NOTE: this may still be a NullRevision!
1196 // New revision!
1197 $this->options['changed'] = true;
1198 } elseif ( $oldId === $revision->getId() ) {
1199 // Null-edit!
1200 $this->options['changed'] = false;
1201 } else {
1202 // This indicates that calling code has given us the wrong Revision object
1203 throw new LogicException(
1204 'The Revision mismatches old revision ID: '
1205 . 'Old ID is ' . $oldId
1206 . ', parent ID is ' . $revision->getParentId()
1207 . ', revision ID is ' . $revision->getId()
1212 // If prepareContent() was used to generate the PST content (which is indicated by
1213 // $this->slotsUpdate being set), and this is not a null-edit, then the given
1214 // revision must have the acting user as the revision author. Otherwise, user
1215 // signatures generated by PST would mismatch the user in the revision record.
1216 if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1217 $user = $revision->getUser();
1218 if ( !$this->user->equals( $user ) ) {
1219 throw new LogicException(
1220 'The Revision provided has a mismatching actor: expected '
1221 . $this->user->getName()
1222 . ', got '
1223 . $user->getName()
1228 // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1229 // emulate the state of the page table before the edit, as good as we can.
1230 if ( !$this->pageState ) {
1231 $this->pageState = [
1232 'oldIsRedirect' => isset( $this->options['oldredirect'] )
1233 && is_bool( $this->options['oldredirect'] )
1234 ? $this->options['oldredirect']
1235 : null,
1236 'oldCountable' => isset( $this->options['oldcountable'] )
1237 && is_bool( $this->options['oldcountable'] )
1238 ? $this->options['oldcountable']
1239 : null,
1242 if ( $this->options['changed'] ) {
1243 // The edit created a new revision
1244 $this->pageState['oldId'] = $revision->getParentId();
1246 if ( isset( $this->options['oldrevision'] ) ) {
1247 $rev = $this->options['oldrevision'];
1248 $this->pageState['oldRevision'] = $rev;
1250 } else {
1251 // This is a null-edit, so the old revision IS the new revision!
1252 $this->pageState['oldId'] = $revision->getId();
1253 $this->pageState['oldRevision'] = $revision;
1257 // "created" is forced here
1258 $this->options['created'] = ( $this->options['created'] ||
1259 ( $this->pageState['oldId'] === 0 ) );
1261 $this->revision = $revision;
1263 $this->doTransition( 'has-revision' );
1265 // NOTE: in case we have a User object, don't override with a UserIdentity.
1266 // We already checked that $revision->getUser() mathces $this->user;
1267 if ( !$this->user ) {
1268 $this->user = $revision->getUser( RevisionRecord::RAW );
1271 // Prune any output that depends on the revision ID.
1272 if ( $this->renderedRevision ) {
1273 $this->renderedRevision->updateRevision( $revision );
1274 } else {
1275 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1276 // NOTE: the revision is either new or current, so we can bypass audience checks.
1277 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1278 $this->revision,
1279 null,
1280 null,
1282 'use-master' => $this->useMaster(),
1283 'audience' => RevisionRecord::RAW,
1284 'known-revision-output' => $options['known-revision-output'] ?? null
1288 // XXX: Since we presumably are dealing with the current revision,
1289 // we could try to get the ParserOutput from the parser cache.
1292 // TODO: optionally get ParserOutput from the ParserCache here.
1293 // Move the logic used by RefreshLinksJob here!
1297 * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1298 * @return PreparedEdit
1300 public function getPreparedEdit() {
1301 $this->assertPrepared( __METHOD__ );
1303 $slotsUpdate = $this->getRevisionSlotsUpdate();
1304 $preparedEdit = new PreparedEdit();
1306 $preparedEdit->popts = $this->getCanonicalParserOptions();
1307 $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
1308 $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
1309 $preparedEdit->newContent =
1310 $slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
1311 ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent()
1312 : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this?
1313 $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1314 $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1315 $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
1316 $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1318 return $preparedEdit;
1322 * @param string $role
1323 * @param bool $generateHtml
1324 * @return ParserOutput
1326 public function getSlotParserOutput( $role, $generateHtml = true ) {
1327 return $this->getRenderedRevision()->getSlotParserOutput(
1328 $role,
1329 [ 'generate-html' => $generateHtml ]
1334 * @return ParserOutput
1336 public function getCanonicalParserOutput() {
1337 return $this->getRenderedRevision()->getRevisionParserOutput();
1341 * @return ParserOptions
1343 public function getCanonicalParserOptions() {
1344 return $this->getRenderedRevision()->getOptions();
1348 * @param bool $recursive
1350 * @return DeferrableUpdate[]
1352 public function getSecondaryDataUpdates( $recursive = false ) {
1353 if ( $this->isContentDeleted() ) {
1354 // This shouldn't happen, since the current content is always public,
1355 // and DataUpates are only needed for current content.
1356 return [];
1359 $output = $this->getCanonicalParserOutput();
1361 // Construct a LinksUpdate for the combined canonical output.
1362 $linksUpdate = new LinksUpdate(
1363 $this->getTitle(),
1364 $output,
1365 $recursive
1368 $allUpdates = [ $linksUpdate ];
1370 // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
1371 // info for an inherited slot may end up being removed. This is also needed
1372 // to ensure that purges are effective.
1373 $renderedRevision = $this->getRenderedRevision();
1374 foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1375 $slot = $this->getRawSlot( $role );
1376 $content = $slot->getContent();
1377 $handler = $content->getContentHandler();
1379 $updates = $handler->getSecondaryDataUpdates(
1380 $this->getTitle(),
1381 $content,
1382 $role,
1383 $renderedRevision
1385 $allUpdates = array_merge( $allUpdates, $updates );
1387 // TODO: remove B/C hack in 1.32!
1388 // NOTE: we assume that the combined output contains all relevant meta-data for
1389 // all slots!
1390 $legacyUpdates = $content->getSecondaryDataUpdates(
1391 $this->getTitle(),
1392 null,
1393 $recursive,
1394 $output
1397 // HACK: filter out redundant and incomplete LinksUpdates
1398 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1399 return !( $update instanceof LinksUpdate );
1400 } );
1402 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1405 // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
1406 // that time, we don't know for which slots to run deletion updates when purging a page.
1407 // We'd have to examine the entire history of the page to determine that. Perhaps there
1408 // could be a "try extra hard" mode for that case that would run a DB query to find all
1409 // roles/models ever used on the page. On the other hand, removing slots should be quite
1410 // rare, so perhaps this isn't worth the trouble.
1412 // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
1413 $wikiPage = $this->getWikiPage();
1414 $parentRevision = $this->getParentRevision();
1415 foreach ( $this->getRemovedSlotRoles() as $role ) {
1416 // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
1417 // For now, find the slot in the parent revision - if the slot was removed, it should
1418 // always exist in the parent revision.
1419 $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
1420 $content = $parentSlot->getContent();
1421 $handler = $content->getContentHandler();
1423 $updates = $handler->getDeletionUpdates(
1424 $this->getTitle(),
1425 $role
1427 $allUpdates = array_merge( $allUpdates, $updates );
1429 // TODO: remove B/C hack in 1.32!
1430 $legacyUpdates = $content->getDeletionUpdates( $wikiPage );
1432 // HACK: filter out redundant and incomplete LinksDeletionUpdate
1433 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1434 return !( $update instanceof LinksDeletionUpdate );
1435 } );
1437 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1440 // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
1441 $this->hookRunner->onRevisionDataUpdates(
1442 $this->getTitle(), $renderedRevision, $allUpdates );
1444 return $allUpdates;
1448 * Do standard updates after page edit, purge, or import.
1449 * Update links tables, site stats, search index, title cache, message cache, etc.
1450 * Purges pages that depend on this page when appropriate.
1451 * With a 10% chance, triggers pruning the recent changes table.
1453 * @note prepareUpdate() must be called before calling this method!
1455 * MCR migration note: this replaces WikiPage::doEditUpdates.
1457 public function doUpdates() {
1458 $this->assertTransition( 'done' );
1460 // TODO: move logic into a PageEventEmitter service
1462 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1464 $legacyUser = User::newFromIdentity( $this->user );
1466 $userParserOptions = ParserOptions::newFromUser( $legacyUser );
1467 // Decide whether to save the final canonical parser ouput based on the fact that
1468 // users are typically redirected to viewing pages right after they edit those pages.
1469 // Due to vary-revision-id, getting/saving that output here might require a reparse.
1470 if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
1471 // Whether getting the final output requires a reparse or not, the user will
1472 // need canonical output anyway, since that is what their parser options use.
1473 // A reparse now at least has the benefit of various warm process caches.
1474 $this->doParserCacheUpdate();
1475 } else {
1476 // If the user does not have canonical parse options, then don't risk another parse
1477 // to make output they cannot use on the page refresh that typically occurs after
1478 // editing. Doing the parser output save post-send will still benefit *other* users.
1479 DeferredUpdates::addCallableUpdate( function () {
1480 $this->doParserCacheUpdate();
1481 } );
1484 $this->doSecondaryDataUpdates( [
1485 // T52785 do not update any other pages on a null edit
1486 'recursive' => $this->options['changed'],
1487 // Defer the getCannonicalParserOutput() call made by getSecondaryDataUpdates()
1488 'defer' => DeferredUpdates::POSTSEND
1489 ] );
1491 // TODO: MCR: check if *any* changed slot supports categories!
1492 if ( $this->rcWatchCategoryMembership
1493 && $this->getContentHandler( SlotRecord::MAIN )->supportsCategories() === true
1494 && ( $this->options['changed'] || $this->options['created'] )
1495 && !$this->options['restored']
1497 // Note: jobs are pushed after deferred updates, so the job should be able to see
1498 // the recent change entry (also done via deferred updates) and carry over any
1499 // bot/deletion/IP flags, ect.
1500 $this->jobQueueGroup->lazyPush(
1501 CategoryMembershipChangeJob::newSpec(
1502 $this->getTitle(),
1503 $this->revision->getTimestamp()
1508 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1509 // @note: Extensions should *avoid* calling getCannonicalParserOutput() when using
1510 // this hook whenever possible in order to avoid unnecessary additional parses.
1511 $editInfo = $this->getPreparedEdit();
1512 $this->hookRunner->onArticleEditUpdates( $wikiPage, $editInfo, $this->options['changed'] );
1514 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1515 if ( $this->hookRunner->onArticleEditUpdatesDeleteFromRecentchanges( $wikiPage ) ) {
1516 // Flush old entries from the `recentchanges` table
1517 if ( mt_rand( 0, 9 ) == 0 ) {
1518 $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
1522 $id = $this->getPageId();
1523 $title = $this->getTitle();
1524 $shortTitle = $title->getDBkey();
1526 if ( !$title->exists() ) {
1527 wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out" );
1529 $this->doTransition( 'done' );
1530 return;
1533 DeferredUpdates::addCallableUpdate( function () {
1534 if (
1535 $this->options['oldcountable'] === 'no-change' ||
1536 ( !$this->options['changed'] && !$this->options['moved'] )
1538 $good = 0;
1539 } elseif ( $this->options['created'] ) {
1540 $good = (int)$this->isCountable();
1541 } elseif ( $this->options['oldcountable'] !== null ) {
1542 $good = (int)$this->isCountable()
1543 - (int)$this->options['oldcountable'];
1544 } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1545 $good = (int)$this->isCountable()
1546 - (int)$this->pageState['oldCountable'];
1547 } else {
1548 $good = 0;
1550 $edits = $this->options['changed'] ? 1 : 0;
1551 $pages = $this->options['created'] ? 1 : 0;
1553 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1554 [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1555 ) );
1556 } );
1558 // TODO: make search infrastructure aware of slots!
1559 $mainSlot = $this->revision->getSlot( SlotRecord::MAIN );
1560 if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1561 DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $mainSlot->getContent() ) );
1564 // If this is another user's talk page, update newtalk.
1565 // Don't do this if $options['changed'] = false (null-edits) nor if
1566 // it's a minor edit and the user making the edit doesn't generate notifications for those.
1567 if ( $this->options['changed']
1568 && $title->getNamespace() === NS_USER_TALK
1569 && $shortTitle != $legacyUser->getTitleKey()
1570 && !( $this->revision->isMinor() && MediaWikiServices::getInstance()
1571 ->getPermissionManager()
1572 ->userHasRight( $legacyUser, 'nominornewtalk' ) )
1574 $recipient = User::newFromName( $shortTitle, false );
1575 if ( !$recipient ) {
1576 wfDebug( __METHOD__ . ": invalid username" );
1577 } else {
1578 // Allow extensions to prevent user notification
1579 // when a new message is added to their talk page
1580 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1581 if ( $this->hookRunner->onArticleEditUpdateNewTalk( $wikiPage, $recipient ) ) {
1582 $revRecord = $this->revision;
1583 $talkPageNotificationManager = MediaWikiServices::getInstance()
1584 ->getTalkPageNotificationManager();
1585 if ( User::isIP( $shortTitle ) ) {
1586 // An anonymous user
1587 $talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
1588 } elseif ( $recipient->isLoggedIn() ) {
1589 $talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
1590 } else {
1591 wfDebug( __METHOD__ . ": don't need to notify a nonexistent user" );
1597 if ( $title->getNamespace() === NS_MEDIAWIKI
1598 && $this->getRevisionSlotsUpdate()->isModifiedSlot( SlotRecord::MAIN )
1600 $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( SlotRecord::MAIN );
1602 $this->messageCache->updateMessageOverride( $title, $mainContent );
1605 // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
1606 if ( $this->options['created'] ) {
1607 WikiPage::onArticleCreate( $title );
1608 } elseif ( $this->options['changed'] ) { // T52785
1609 WikiPage::onArticleEdit( $title, $this->revision, $this->getTouchedSlotRoles() );
1610 } elseif ( $this->options['restored'] ) {
1611 MediaWikiServices::getInstance()->getMainWANObjectCache()->touchCheckKey(
1612 "DerivedPageDataUpdater:restore:page:$id"
1616 $oldRevisionRecord = $this->getParentRevision();
1618 // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1619 ResourceLoaderWikiModule::invalidateModuleCache(
1620 $title,
1621 $oldRevisionRecord,
1622 $this->revision,
1623 $this->loadbalancerFactory->getLocalDomainID()
1626 // Schedule a deferred update for marking reverted edits if applicable.
1627 $this->maybeEnqueueRevertedTagUpdateJob();
1629 $this->doTransition( 'done' );
1633 * If the edit was a revert and it is considered "approved", enqueues the
1634 * RevertedTagUpdateJob for it. If the edit is not yet approved, the EditResult is
1635 * persisted in cache for later use.
1637 private function maybeEnqueueRevertedTagUpdateJob() {
1638 if ( $this->options['editResult'] === null ) {
1639 return;
1642 $editResult = $this->options['editResult'];
1643 if ( !$editResult->isRevert() ) {
1644 return;
1647 if ( $this->options['approved'] ) {
1648 // Enqueue the job
1649 $this->jobQueueGroup->lazyPush(
1650 RevertedTagUpdateJob::newSpec(
1651 $this->revision->getId(),
1652 $this->options['editResult']
1655 } else {
1656 // Cache EditResult for later use
1657 $this->editResultCache->set(
1658 $this->revision->getId(),
1659 $this->options['editResult']
1665 * Do secondary data updates (e.g. updating link tables) or schedule them as deferred updates
1667 * MCR note: this method is temporarily exposed via WikiPage::doSecondaryDataUpdates.
1669 * @param array $options
1670 * - recursive: make the update recursive, i.e. also update pages which transclude the
1671 * current page or otherwise depend on it (default: false)
1672 * - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1673 * for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1674 * @since 1.32
1676 public function doSecondaryDataUpdates( array $options = [] ) {
1677 $this->assertHasRevision( __METHOD__ );
1678 $options += [ 'recursive' => false, 'defer' => false ];
1679 $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1680 if ( !in_array( $options['defer'], $deferValues, true ) ) {
1681 throw new InvalidArgumentException( 'Invalid value for defer: ' . $options['defer'] );
1684 $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1685 if ( !$triggeringUser instanceof User ) {
1686 $triggeringUser = User::newFromIdentity( $triggeringUser );
1688 $causeAction = $this->options['causeAction'] ?? 'unknown';
1689 $causeAgent = $this->options['causeAgent'] ?? 'unknown';
1691 // Bundle all of the data updates into a single deferred update wrapper so that
1692 // any failure will cause at most one refreshLinks job to be enqueued by
1693 // DeferredUpdates::doUpdates(). This is hard to do when there are many separate
1694 // updates that are not defined as being related.
1695 $update = new RefreshSecondaryDataUpdate(
1696 $this->loadbalancerFactory,
1697 $triggeringUser,
1698 $this->wikiPage,
1699 $this->revision,
1700 $this,
1701 [ 'recursive' => $options['recursive'] ]
1703 $update->setCause( $causeAction, $causeAgent );
1705 if ( $options['defer'] === false ) {
1706 DeferredUpdates::attemptUpdate( $update, $this->loadbalancerFactory );
1707 } else {
1708 DeferredUpdates::addUpdate( $update, $options['defer'] );
1712 public function doParserCacheUpdate() {
1713 $this->assertHasRevision( __METHOD__ );
1715 $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1717 // NOTE: this may trigger the first parsing of the new content after an edit (when not
1718 // using pre-generated stashed output).
1719 // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1720 // to be performed post-send. The client could already follow a HTTP redirect to the
1721 // page view, but would then have to wait for a response until rendering is complete.
1722 $output = $this->getCanonicalParserOutput();
1724 // Save it to the parser cache. Use the revision timestamp in the case of a
1725 // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1726 // unnecessary reparse.
1727 $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp()
1728 : $output->getCacheTime();
1729 $this->parserCache->save(
1730 $output, $wikiPage, $this->getCanonicalParserOptions(),
1731 $timestamp, $this->revision->getId()