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
23 namespace MediaWiki\Storage
;
26 use CategoryMembershipChangeJob
;
33 use InvalidArgumentException
;
38 use MediaWiki\Edit\PreparedEdit
;
39 use MediaWiki\Revision\RenderedRevision
;
40 use MediaWiki\Revision\RevisionRenderer
;
41 use MediaWiki\User\UserIdentity
;
46 use RecentChangesUpdateJob
;
47 use ResourceLoaderWikiModule
;
53 use Wikimedia\Assert\Assert
;
54 use Wikimedia\Rdbms\LBFactory
;
58 * A handle for managing updates for derived page data on edit, import, purge, etc.
60 * @note Avoid direct usage of DerivedPageDataUpdater.
62 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
63 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
64 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
65 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
66 * Content::getSecondaryDataUpdates().
68 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
69 * and re-used by callback code over the course of an update operation. It's a stepping stone
70 * one the way to a more complete refactoring of WikiPage.
72 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
73 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
74 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
75 * require prepareContent or prepareUpdate to have been called first, to initialize the
76 * DerivedPageDataUpdater.
78 * @see docs/pageupdater.txt for more information.
80 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
88 class DerivedPageDataUpdater
implements IDBAccessObject
{
91 * @var UserIdentity|null
103 private $parserCache;
108 private $revisionStore;
118 private $jobQueueGroup;
123 private $messageCache;
128 private $loadbalancerFactory;
131 * @var string see $wgArticleCountMethod
133 private $articleCountMethod;
136 * @var boolean see $wgRCWatchCategoryMembership
138 private $rcWatchCategoryMembership = false;
141 * Stores (most of) the $options parameter of prepareUpdate().
142 * @see prepareUpdate()
149 'oldrevision' => null,
150 'oldcountable' => null,
151 'oldredirect' => null,
152 'triggeringUser' => null,
153 // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
154 // to make the life of prepareUpdate() callers easier.
155 'causeAction' => null,
156 'causeAgent' => null,
160 * The state of the relevant row in page table before the edit.
161 * This is determined by the first call to grabCurrentRevision, prepareContent,
162 * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
163 * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
164 * attempt to emulate the state of the page table before the edit.
166 * Contains the following fields:
167 * - oldRevision (RevisionRecord|null): the revision that was current before the change
168 * associated with this update. Might not be set, use getOldRevision() instead of direct
170 * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
171 * was about creating a new page); null if not known (that should not happen).
172 * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
173 * can be null; use wasRedirect() instead of direct access.
174 * - oldCountable (bool|null): whether the page was countable before the change (or null
175 * if we don't have that information)
179 private $pageState = null;
182 * @var RevisionSlotsUpdate|null
184 private $slotsUpdate = null;
187 * @var RevisionRecord|null
189 private $revision = null;
192 * @var RenderedRevision
194 private $renderedRevision = null;
197 * @var RevisionRenderer
199 private $revisionRenderer;
202 * A stage identifier for managing the life cycle of this instance.
203 * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
205 * @see docs/pageupdater.txt for documentation of the life cycle.
209 private $stage = 'new';
212 * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
214 * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
215 * and constants are also overkill...
217 * @see docs/pageupdater.txt for documentation of the life cycle.
221 private static $transitions = [
224 'knows-current' => true,
225 'has-content' => true,
226 'has-revision' => true,
229 'knows-current' => true,
230 'has-content' => true,
231 'has-revision' => true,
234 'has-content' => true,
235 'has-revision' => true,
238 'has-revision' => true,
244 * @param WikiPage $wikiPage ,
245 * @param RevisionStore $revisionStore
246 * @param RevisionRenderer $revisionRenderer
247 * @param ParserCache $parserCache
248 * @param JobQueueGroup $jobQueueGroup
249 * @param MessageCache $messageCache
250 * @param Language $contLang
251 * @param LBFactory $loadbalancerFactory
253 public function __construct(
255 RevisionStore
$revisionStore,
256 RevisionRenderer
$revisionRenderer,
257 ParserCache
$parserCache,
258 JobQueueGroup
$jobQueueGroup,
259 MessageCache
$messageCache,
261 LBFactory
$loadbalancerFactory
263 $this->wikiPage
= $wikiPage;
265 $this->parserCache
= $parserCache;
266 $this->revisionStore
= $revisionStore;
267 $this->revisionRenderer
= $revisionRenderer;
268 $this->jobQueueGroup
= $jobQueueGroup;
269 $this->messageCache
= $messageCache;
270 $this->contLang
= $contLang;
271 // XXX only needed for waiting for slaves to catch up; there should be a narrower
272 // interface for that.
273 $this->loadbalancerFactory
= $loadbalancerFactory;
277 * Transition function for managing the life cycle of this instances.
279 * @see docs/pageupdater.txt for documentation of the life cycle.
281 * @param string $newStage the new stage
282 * @return string the previous stage
284 * @throws LogicException If a transition to the given stage is not possible in the current
287 private function doTransition( $newStage ) {
288 $this->assertTransition( $newStage );
290 $oldStage = $this->stage
;
291 $this->stage
= $newStage;
297 * Asserts that a transition to the given stage is possible, without performing it.
299 * @see docs/pageupdater.txt for documentation of the life cycle.
301 * @param string $newStage the new stage
303 * @throws LogicException If this instance is not in the expected stage
305 private function assertTransition( $newStage ) {
306 if ( empty( self
::$transitions[$this->stage
][$newStage] ) ) {
307 throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
312 * @return bool|string
314 private function getWikiId() {
315 // TODO: get from RevisionStore
320 * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
321 * the given revision.
323 * @param UserIdentity|null $user The user creating the revision in question
324 * @param RevisionRecord|null $revision New revision (after save, if already saved)
325 * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
326 * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
330 public function isReusableFor(
331 UserIdentity
$user = null,
332 RevisionRecord
$revision = null,
333 RevisionSlotsUpdate
$slotsUpdate = null,
338 && $revision->getParentId() !== $parentId
340 throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
345 && $revision->getUser( RevisionRecord
::RAW
)->getName() !== $user->getName()
347 throw new InvalidArgumentException( '$user should match the author of $revision' );
350 if ( $user && $this->user
&& $user->getName() !== $this->user
->getName() ) {
354 if ( $revision && $this->revision
&& $this->revision
->getId()
355 && $this->revision
->getId() !== $revision->getId()
360 if ( $revision && !$user ) {
361 $user = $revision->getUser( RevisionRecord
::RAW
);
364 if ( $this->pageState
366 && $revision->getParentId() !== null
367 && $this->pageState
['oldId'] !== $revision->getParentId()
372 if ( $this->pageState
373 && $parentId !== null
374 && $this->pageState
['oldId'] !== $parentId
381 && $this->revision
->getUser( RevisionRecord
::RAW
)
382 && $this->revision
->getUser( RevisionRecord
::RAW
)->getName() !== $user->getName()
389 && $this->revision
->getUser( RevisionRecord
::RAW
)
390 && $revision->getUser( RevisionRecord
::RAW
)->getName() !== $this->user
->getName()
395 // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
396 if ( $this->slotsUpdate
398 && !$this->slotsUpdate
->hasSameUpdates( $slotsUpdate )
405 && !$this->revision
->getSlots()->hasSameContent( $revision->getSlots() )
414 * @param string $articleCountMethod "any" or "link".
415 * @see $wgArticleCountMethod
417 public function setArticleCountMethod( $articleCountMethod ) {
418 $this->articleCountMethod
= $articleCountMethod;
422 * @param bool $rcWatchCategoryMembership
423 * @see $wgRCWatchCategoryMembership
425 public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
426 $this->rcWatchCategoryMembership
= $rcWatchCategoryMembership;
432 private function getTitle() {
433 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
434 return $this->wikiPage
->getTitle();
440 private function getWikiPage() {
441 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
442 return $this->wikiPage
;
446 * Determines whether the page being edited already existed.
447 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
450 * @throws LogicException if called before grabCurrentRevision
452 public function pageExisted() {
453 $this->assertHasPageState( __METHOD__
);
455 return $this->pageState
['oldId'] > 0;
459 * Returns the revision that was current before the edit. This would be null if the edit
460 * created the page, or the revision's parent for a regular edit, or the revision itself
462 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
464 * @return RevisionRecord|null the revision that was current before the edit, or null if
465 * the edit created the page.
467 private function getOldRevision() {
468 $this->assertHasPageState( __METHOD__
);
470 // If 'oldRevision' is not set, load it!
471 // Useful if $this->oldPageState is initialized by prepareUpdate.
472 if ( !array_key_exists( 'oldRevision', $this->pageState
) ) {
473 /** @var int $oldId */
474 $oldId = $this->pageState
['oldId'];
475 $flags = $this->useMaster() ? RevisionStore
::READ_LATEST
: 0;
476 $this->pageState
['oldRevision'] = $oldId
477 ?
$this->revisionStore
->getRevisionById( $oldId, $flags )
481 return $this->pageState
['oldRevision'];
485 * Returns the revision that was the page's current revision when grabCurrentRevision()
488 * During an edit, that revision will act as the logical parent of the new revision.
490 * Some updates are performed based on the difference between the database state at the
491 * moment this method is first called, and the state after the edit.
493 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
495 * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
496 * to avoid confusion, since the page's current revision is then the new revision after
497 * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
498 * Use getOldRevision() instead to access the revision that used to be current before the
501 * @return RevisionRecord|null the page's current revision, or null if the page does not
504 public function grabCurrentRevision() {
505 if ( $this->pageState
) {
506 return $this->pageState
['oldRevision'];
509 $this->assertTransition( 'knows-current' );
511 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
512 $wikiPage = $this->getWikiPage();
514 // Do not call WikiPage::clear(), since the caller may already have caused page data
515 // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
516 $wikiPage->loadPageData( self
::READ_LATEST
);
517 $rev = $wikiPage->getRevision();
518 $current = $rev ?
$rev->getRevisionRecord() : null;
521 'oldRevision' => $current,
522 'oldId' => $rev ?
$rev->getId() : 0,
523 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
524 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
527 $this->doTransition( 'knows-current' );
529 return $this->pageState
['oldRevision'];
533 * Whether prepareUpdate() or prepareContent() have been called on this instance.
537 public function isContentPrepared() {
538 return $this->revision
!== null;
542 * Whether prepareUpdate() has been called on this instance.
544 * @note will also return null in case of a null-edit!
548 public function isUpdatePrepared() {
549 return $this->revision
!== null && $this->revision
->getId() !== null;
555 private function getPageId() {
556 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
557 return $this->wikiPage
->getId();
561 * Whether the content is deleted and thus not visible to the public.
565 public function isContentDeleted() {
566 if ( $this->revision
) {
567 // XXX: if that revision is the current revision, this should be skipped
568 return $this->revision
->isDeleted( RevisionRecord
::DELETED_TEXT
);
570 // If the content has not been saved yet, it cannot have been deleted yet.
576 * Returns the slot, modified or inherited, after PST, with no audience checks applied.
578 * @param string $role slot role name
580 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
584 public function getRawSlot( $role ) {
585 return $this->getSlots()->getSlot( $role );
589 * Returns the content of the given slot, with no audience checks.
591 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
593 * @param string $role slot role name
596 public function getRawContent( $role ) {
597 return $this->getRawSlot( $role )->getContent();
601 * Returns the content model of the given slot
603 * @param string $role slot role name
606 private function getContentModel( $role ) {
607 return $this->getRawSlot( $role )->getModel();
611 * @param string $role slot role name
612 * @return ContentHandler
614 private function getContentHandler( $role ) {
615 // TODO: inject something like a ContentHandlerRegistry
616 return ContentHandler
::getForModelID( $this->getContentModel( $role ) );
619 private function useMaster() {
620 // TODO: can we just set a flag to true in prepareContent()?
621 return $this->wikiPage
->wasLoadedFrom( self
::READ_LATEST
);
627 public function isCountable() {
628 // NOTE: Keep in sync with WikiPage::isCountable.
630 if ( !$this->getTitle()->isContentPage() ) {
634 if ( $this->isContentDeleted() ) {
635 // This should be irrelevant: countability only applies to the current revision,
636 // and the current revision is never suppressed.
640 if ( $this->isRedirect() ) {
646 if ( $this->articleCountMethod
=== 'link' ) {
647 $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
650 // TODO: MCR: ask all slots if they have links [SlotHandler/PageTypeHandler]
651 $mainContent = $this->getRawContent( 'main' );
652 return $mainContent->isCountable( $hasLinks );
658 public function isRedirect() {
659 // NOTE: main slot determines redirect status
660 $mainContent = $this->getRawContent( 'main' );
662 return $mainContent->isRedirect();
666 * @param RevisionRecord $rev
670 private function revisionIsRedirect( RevisionRecord
$rev ) {
671 // NOTE: main slot determines redirect status
672 $mainContent = $rev->getContent( 'main', RevisionRecord
::RAW
);
674 return $mainContent->isRedirect();
678 * Prepare updates based on an update which has not yet been saved.
680 * This may be used to create derived data that is needed when creating a new revision;
681 * particularly, this makes available the slots of the new revision via the getSlots()
682 * method, after applying PST and slot inheritance.
684 * The derived data prepared for revision creation may then later be re-used by doUpdates(),
685 * without the need to re-calculate.
687 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
689 * @note Calling this method more than once with the same $slotsUpdate
690 * has no effect. Calling this method multiple times with different content will cause
693 * @note Calling this method after prepareUpdate() has been called will cause an exception.
695 * @param User $user The user to act as context for pre-save transformation (PST).
696 * Type hint should be reduced to UserIdentity at some point.
697 * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
698 * by this edit, before PST.
699 * @param bool $useStash Whether to use stashed ParserOutput
701 public function prepareContent(
703 RevisionSlotsUpdate
$slotsUpdate,
706 if ( $this->slotsUpdate
) {
707 if ( !$this->user
) {
708 throw new LogicException(
709 'Unexpected state: $this->slotsUpdate was initialized, '
710 . 'but $this->user was not.'
714 if ( $this->user
->getName() !== $user->getName() ) {
715 throw new LogicException( 'Can\'t call prepareContent() again for different user! '
716 . 'Expected ' . $this->user
->getName() . ', got ' . $user->getName()
720 if ( !$this->slotsUpdate
->hasSameUpdates( $slotsUpdate ) ) {
721 throw new LogicException(
722 'Can\'t call prepareContent() again with different slot content!'
726 return; // prepareContent() already done, nothing to do
729 $this->assertTransition( 'has-content' );
731 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
732 $title = $this->getTitle();
734 $parentRevision = $this->grabCurrentRevision();
736 $this->slotsOutput
= [];
737 $this->canonicalParserOutput
= null;
739 // The edit may have already been prepared via api.php?action=stashedit
740 $stashedEdit = false;
742 // TODO: MCR: allow output for all slots to be stashed.
743 if ( $useStash && $slotsUpdate->isModifiedSlot( 'main' ) ) {
744 $mainContent = $slotsUpdate->getModifiedSlot( 'main' )->getContent();
745 $legacyUser = User
::newFromIdentity( $user );
746 $stashedEdit = ApiStashEdit
::checkCache( $title, $mainContent, $legacyUser );
749 if ( $stashedEdit ) {
750 /** @var ParserOutput $output */
751 $output = $stashedEdit->output
;
753 // TODO: this should happen when stashing the ParserOutput, not now!
754 $output->setCacheTime( $stashedEdit->timestamp
);
756 // TODO: MCR: allow output for all slots to be stashed.
757 $this->canonicalParserOutput
= $output;
760 $userPopts = ParserOptions
::newFromUserAndLang( $user, $this->contLang
);
761 Hooks
::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
764 $this->slotsUpdate
= $slotsUpdate;
766 if ( $parentRevision ) {
767 $this->revision
= MutableRevisionRecord
::newFromParentRevision( $parentRevision );
769 $this->revision
= new MutableRevisionRecord( $title );
772 // NOTE: user and timestamp must be set, so they can be used for
773 // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
774 $this->revision
->setTimestamp( wfTimestampNow() );
775 $this->revision
->setUser( $user );
777 // Set up ParserOptions to operate on the new revision
778 $oldCallback = $userPopts->getCurrentRevisionCallback();
779 $userPopts->setCurrentRevisionCallback(
780 function ( Title
$parserTitle, $parser = false ) use ( $title, $oldCallback ) {
781 if ( $parserTitle->equals( $title ) ) {
782 $legacyRevision = new Revision( $this->revision
);
783 return $legacyRevision;
785 return call_user_func( $oldCallback, $parserTitle, $parser );
790 $pstContentSlots = $this->revision
->getSlots();
792 foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
793 $slot = $slotsUpdate->getModifiedSlot( $role );
795 if ( $slot->isInherited() ) {
796 // No PST for inherited slots! Note that "modified" slots may still be inherited
797 // from an earlier version, e.g. for rollbacks.
799 } elseif ( $role === 'main' && $stashedEdit ) {
800 // TODO: MCR: allow PST content for all slots to be stashed.
801 $pstSlot = SlotRecord
::newUnsaved( $role, $stashedEdit->pstContent
);
803 $content = $slot->getContent();
804 $pstContent = $content->preSaveTransform( $title, $this->user
, $userPopts );
805 $pstSlot = SlotRecord
::newUnsaved( $role, $pstContent );
808 $pstContentSlots->setSlot( $pstSlot );
811 foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
812 $pstContentSlots->removeSlot( $role );
815 $this->options
['created'] = ( $parentRevision === null );
816 $this->options
['changed'] = ( $parentRevision === null
817 ||
!$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
819 $this->doTransition( 'has-content' );
821 if ( !$this->options
['changed'] ) {
824 // TODO: move this into MutableRevisionRecord
825 // TODO: This needs to behave differently for a forced dummy edit!
826 $this->revision
->setId( $parentRevision->getId() );
827 $this->revision
->setTimestamp( $parentRevision->getTimestamp() );
828 $this->revision
->setPageId( $parentRevision->getPageId() );
829 $this->revision
->setParentId( $parentRevision->getParentId() );
830 $this->revision
->setUser( $parentRevision->getUser( RevisionRecord
::RAW
) );
831 $this->revision
->setComment( $parentRevision->getComment( RevisionRecord
::RAW
) );
832 $this->revision
->setMinorEdit( $parentRevision->isMinor() );
833 $this->revision
->setVisibility( $parentRevision->getVisibility() );
835 // prepareUpdate() is redundant for null-edits
836 $this->doTransition( 'has-revision' );
841 * Returns the update's target revision - that is, the revision that will be the current
842 * revision after the update.
844 * @note Callers must treat the returned RevisionRecord's content as immutable, even
845 * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
846 * returned from here, such as the user or the comment, may be changed, but may not
847 * be reflected in ParserOutput until after prepareUpdate() has been called.
849 * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
850 * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
851 * for that purpose instead!
853 * @return RevisionRecord
855 public function getRevision() {
856 $this->assertPrepared( __METHOD__
);
857 return $this->revision
;
861 * @return RenderedRevision
863 public function getRenderedRevision() {
864 if ( !$this->renderedRevision
) {
865 $this->assertPrepared( __METHOD__
);
867 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
868 // NOTE: the revision is either new or current, so we can bypass audience checks.
869 $this->renderedRevision
= $this->revisionRenderer
->getRenderedRevision(
873 [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord
::RAW
]
877 return $this->renderedRevision
;
880 private function assertHasPageState( $method ) {
881 if ( !$this->pageState
) {
882 throw new LogicException(
883 'Must call grabCurrentRevision() or prepareContent() '
884 . 'or prepareUpdate() before calling ' . $method
889 private function assertPrepared( $method ) {
890 if ( !$this->revision
) {
891 throw new LogicException(
892 'Must call prepareContent() or prepareUpdate() before calling ' . $method
897 private function assertHasRevision( $method ) {
898 if ( !$this->revision
->getId() ) {
899 throw new LogicException(
900 'Must call prepareUpdate() before calling ' . $method
906 * Whether the edit creates the page.
910 public function isCreation() {
911 $this->assertPrepared( __METHOD__
);
912 return $this->options
['created'];
916 * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
918 * @warning at present, "null-revisions" that do not change content but do have a revision
919 * record would return false after prepareContent(), but true after prepareUpdate()!
920 * This should probably be fixed.
924 public function isChange() {
925 $this->assertPrepared( __METHOD__
);
926 return $this->options
['changed'];
930 * Whether the page was a redirect before the edit.
934 public function wasRedirect() {
935 $this->assertHasPageState( __METHOD__
);
937 if ( $this->pageState
['oldIsRedirect'] === null ) {
938 /** @var RevisionRecord $rev */
939 $rev = $this->pageState
['oldRevision'];
941 $this->pageState
['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
943 $this->pageState
['oldIsRedirect'] = false;
947 return $this->pageState
['oldIsRedirect'];
951 * Returns the slots of the target revision, after PST.
953 * @note Callers must treat the returned RevisionSlots instance as immutable, even
954 * if it is a MutableRevisionSlots instance.
956 * @return RevisionSlots
958 public function getSlots() {
959 $this->assertPrepared( __METHOD__
);
960 return $this->revision
->getSlots();
964 * Returns the RevisionSlotsUpdate for this updater.
966 * @return RevisionSlotsUpdate
968 private function getRevisionSlotsUpdate() {
969 $this->assertPrepared( __METHOD__
);
971 if ( !$this->slotsUpdate
) {
972 $old = $this->getOldRevision();
973 $this->slotsUpdate
= RevisionSlotsUpdate
::newFromRevisionSlots(
974 $this->revision
->getSlots(),
975 $old ?
$old->getSlots() : null
978 return $this->slotsUpdate
;
982 * Returns the role names of the slots touched by the new revision,
983 * including removed roles.
987 public function getTouchedSlotRoles() {
988 return $this->getRevisionSlotsUpdate()->getTouchedRoles();
992 * Returns the role names of the slots modified by the new revision,
993 * not including removed roles.
997 public function getModifiedSlotRoles() {
998 return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1002 * Returns the role names of the slots removed by the new revision.
1006 public function getRemovedSlotRoles() {
1007 return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1011 * Prepare derived data updates targeting the given Revision.
1013 * Calling this method requires the given revision to be present in the database.
1014 * This may be right after a new revision has been created, or when re-generating
1015 * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1018 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
1020 * @note Calling this method more than once with the same revision has no effect.
1021 * $options are only used for the first call. Calling this method multiple times with
1022 * different revisions will cause an exception.
1024 * @note If grabCurrentRevision() (or prepareContent()) has been called before
1025 * calling this method, $revision->getParentRevision() has to refer to the revision that
1026 * was the current revision at the time grabCurrentRevision() was called.
1028 * @param RevisionRecord $revision
1029 * @param array $options Array of options, following indexes are used:
1030 * - changed: bool, whether the revision changed the content (default true)
1031 * - created: bool, whether the revision created the page (default false)
1032 * - moved: bool, whether the page was moved (default false)
1033 * - restored: bool, whether the page was undeleted (default false)
1034 * - oldrevision: Revision object for the pre-update revision (default null)
1035 * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1036 * user who created the revision)
1037 * - oldredirect: bool, null, or string 'no-change' (default null):
1038 * - bool: whether the page was counted as a redirect before that
1039 * revision, only used in changed is true and created is false
1040 * - null or 'no-change': don't update the redirect status.
1041 * - oldcountable: bool, null, or string 'no-change' (default null):
1042 * - bool: whether the page was counted as an article before that
1043 * revision, only used in changed is true and created is false
1044 * - null: if created is false, don't update the article count; if created
1045 * is true, do update the article count
1046 * - 'no-change': don't update the article count, ever
1047 * When set to null, pageState['oldCountable'] will be used instead if available.
1048 * - causeAction: an arbitrary string identifying the reason for the update.
1049 * See DataUpdate::getCauseAction(). (default 'unknown')
1050 * - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1051 * (string, default 'unknown')
1053 public function prepareUpdate( RevisionRecord
$revision, array $options = [] ) {
1055 !isset( $options['oldrevision'] )
1056 ||
$options['oldrevision'] instanceof Revision
1057 ||
$options['oldrevision'] instanceof RevisionRecord
,
1058 '$options["oldrevision"]',
1059 'must be a RevisionRecord (or Revision)'
1062 !isset( $options['triggeringUser'] )
1063 ||
$options['triggeringUser'] instanceof UserIdentity
,
1064 '$options["triggeringUser"]',
1065 'must be a UserIdentity'
1068 if ( !$revision->getId() ) {
1069 throw new InvalidArgumentException(
1070 'Revision must have an ID set for it to be used with prepareUpdate()!'
1074 if ( $this->revision
&& $this->revision
->getId() ) {
1075 if ( $this->revision
->getId() === $revision->getId() ) {
1076 return; // nothing to do!
1078 throw new LogicException(
1079 'Trying to re-use DerivedPageDataUpdater with revision '
1080 . $revision->getId()
1081 . ', but it\'s already bound to revision '
1082 . $this->revision
->getId()
1087 if ( $this->revision
1088 && !$this->revision
->getSlots()->hasSameContent( $revision->getSlots() )
1090 throw new LogicException(
1091 'The Revision provided has mismatching content!'
1095 // Override fields defined in $this->options with values from $options.
1096 $this->options
= array_intersect_key( $options, $this->options
) +
$this->options
;
1098 if ( isset( $this->pageState
['oldId'] ) ) {
1099 $oldId = $this->pageState
['oldId'];
1100 } elseif ( isset( $this->options
['oldrevision'] ) ) {
1101 /** @var Revision|RevisionRecord $oldRev */
1102 $oldRev = $this->options
['oldrevision'];
1103 $oldId = $oldRev->getId();
1105 $oldId = $revision->getParentId();
1108 if ( $oldId !== null ) {
1109 // XXX: what if $options['changed'] disagrees?
1110 // MovePage creates a dummy revision with changed = false!
1111 // We may want to explicitly distinguish between "no new revision" (null-edit)
1112 // and "new revision without new content" (dummy revision).
1114 if ( $oldId === $revision->getParentId() ) {
1115 // NOTE: this may still be a NullRevision!
1117 $this->options
['changed'] = true;
1118 } elseif ( $oldId === $revision->getId() ) {
1120 $this->options
['changed'] = false;
1122 // This indicates that calling code has given us the wrong Revision object
1123 throw new LogicException(
1124 'The Revision mismatches old revision ID: '
1125 . 'Old ID is ' . $oldId
1126 . ', parent ID is ' . $revision->getParentId()
1127 . ', revision ID is ' . $revision->getId()
1132 // If prepareContent() was used to generate the PST content (which is indicated by
1133 // $this->slotsUpdate being set), and this is not a null-edit, then the given
1134 // revision must have the acting user as the revision author. Otherwise, user
1135 // signatures generated by PST would mismatch the user in the revision record.
1136 if ( $this->user
!== null && $this->options
['changed'] && $this->slotsUpdate
) {
1137 $user = $revision->getUser();
1138 if ( !$this->user
->equals( $user ) ) {
1139 throw new LogicException(
1140 'The Revision provided has a mismatching actor: expected '
1141 . $this->user
->getName()
1148 // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1149 // emulate the state of the page table before the edit, as good as we can.
1150 if ( !$this->pageState
) {
1151 $this->pageState
= [
1152 'oldIsRedirect' => isset( $this->options
['oldredirect'] )
1153 && is_bool( $this->options
['oldredirect'] )
1154 ?
$this->options
['oldredirect']
1156 'oldCountable' => isset( $this->options
['oldcountable'] )
1157 && is_bool( $this->options
['oldcountable'] )
1158 ?
$this->options
['oldcountable']
1162 if ( $this->options
['changed'] ) {
1163 // The edit created a new revision
1164 $this->pageState
['oldId'] = $revision->getParentId();
1166 if ( isset( $this->options
['oldrevision'] ) ) {
1167 $rev = $this->options
['oldrevision'];
1168 $this->pageState
['oldRevision'] = $rev instanceof Revision
1169 ?
$rev->getRevisionRecord()
1173 // This is a null-edit, so the old revision IS the new revision!
1174 $this->pageState
['oldId'] = $revision->getId();
1175 $this->pageState
['oldRevision'] = $revision;
1179 // "created" is forced here
1180 $this->options
['created'] = ( $this->pageState
['oldId'] === 0 );
1182 $this->revision
= $revision;
1184 $this->doTransition( 'has-revision' );
1186 // NOTE: in case we have a User object, don't override with a UserIdentity.
1187 // We already checked that $revision->getUser() mathces $this->user;
1188 if ( !$this->user
) {
1189 $this->user
= $revision->getUser( RevisionRecord
::RAW
);
1192 // Prune any output that depends on the revision ID.
1193 if ( $this->renderedRevision
) {
1194 $this->renderedRevision
->updateRevision( $revision );
1197 // TODO: optionally get ParserOutput from the ParserCache here.
1198 // Move the logic used by RefreshLinksJob here!
1202 * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1203 * @return PreparedEdit
1205 public function getPreparedEdit() {
1206 $this->assertPrepared( __METHOD__
);
1208 $slotsUpdate = $this->getRevisionSlotsUpdate();
1209 $preparedEdit = new PreparedEdit();
1211 $preparedEdit->popts
= $this->getCanonicalParserOptions();
1212 $preparedEdit->output
= $this->getCanonicalParserOutput();
1213 $preparedEdit->pstContent
= $this->revision
->getContent( 'main' );
1214 $preparedEdit->newContent
=
1215 $slotsUpdate->isModifiedSlot( 'main' )
1216 ?
$slotsUpdate->getModifiedSlot( 'main' )->getContent()
1217 : $this->revision
->getContent( 'main' ); // XXX: can we just remove this?
1218 $preparedEdit->oldContent
= null; // unused. // XXX: could get this from the parent revision
1219 $preparedEdit->revid
= $this->revision ?
$this->revision
->getId() : null;
1220 $preparedEdit->timestamp
= $preparedEdit->output
->getCacheTime();
1221 $preparedEdit->format
= $preparedEdit->pstContent
->getDefaultFormat();
1223 return $preparedEdit;
1227 * @param string $role
1228 * @param bool $generateHtml
1229 * @return ParserOutput
1231 public function getSlotParserOutput( $role, $generateHtml = true ) {
1232 return $this->getRenderedRevision()->getSlotParserOutput(
1234 [ 'generate-html' => $generateHtml ]
1239 * @return ParserOutput
1241 public function getCanonicalParserOutput() {
1242 return $this->getRenderedRevision()->getRevisionParserOutput();
1246 * @return ParserOptions
1248 public function getCanonicalParserOptions() {
1249 return $this->getRenderedRevision()->getOptions();
1253 * @param bool $recursive
1255 * @return DataUpdate[]
1257 public function getSecondaryDataUpdates( $recursive = false ) {
1258 // TODO: MCR: getSecondaryDataUpdates() needs a complete overhaul to avoid DataUpdates
1259 // from different slots overwriting each other in the database. Plan:
1260 // * replace direct calls to Content::getSecondaryDataUpdates() with calls to this method
1261 // * Construct LinksUpdate here, on the combined ParserOutput, instead of in AbstractContent
1263 // * Pass $slot into getSecondaryDataUpdates() - probably be introducing a new duplicate
1264 // version of this function in ContentHandler.
1265 // * The new method gets the PreparedEdit, but no $recursive flag (that's for LinksUpdate)
1266 // * Hack: call both the old and the new getSecondaryDataUpdates method here; Pass
1267 // the per-slot ParserOutput to the old method, for B/C.
1268 // * Hack: If there is more than one slot, filter LinksUpdate from the DataUpdates
1269 // returned by getSecondaryDataUpdates, and use a LinksUpdated for the combined output
1271 // * Call the SecondaryDataUpdates hook here (or kill it - its signature doesn't make sense)
1273 $content = $this->getSlots()->getContent( 'main' );
1275 // NOTE: $output is the combined output, to be shown in the default view.
1276 $output = $this->getCanonicalParserOutput();
1278 $updates = $content->getSecondaryDataUpdates(
1279 $this->getTitle(), null, $recursive, $output
1286 * Do standard updates after page edit, purge, or import.
1287 * Update links tables, site stats, search index, title cache, message cache, etc.
1288 * Purges pages that depend on this page when appropriate.
1289 * With a 10% chance, triggers pruning the recent changes table.
1291 * @note prepareUpdate() must be called before calling this method!
1293 * MCR migration note: this replaces WikiPage::doEditUpdates.
1295 public function doUpdates() {
1296 $this->assertTransition( 'done' );
1298 // TODO: move logic into a PageEventEmitter service
1300 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1302 $legacyUser = User
::newFromIdentity( $this->user
);
1303 $legacyRevision = new Revision( $this->revision
);
1305 $this->doParserCacheUpdate();
1307 $this->doSecondaryDataUpdates( [
1308 // T52785 do not update any other pages on a null edit
1309 'recursive' => $this->options
['changed'],
1310 'defer' => DeferredUpdates
::POSTSEND
,
1313 // TODO: MCR: check if *any* changed slot supports categories!
1314 if ( $this->rcWatchCategoryMembership
1315 && $this->getContentHandler( 'main' )->supportsCategories() === true
1316 && ( $this->options
['changed'] ||
$this->options
['created'] )
1317 && !$this->options
['restored']
1319 // Note: jobs are pushed after deferred updates, so the job should be able to see
1320 // the recent change entry (also done via deferred updates) and carry over any
1321 // bot/deletion/IP flags, ect.
1322 $this->jobQueueGroup
->lazyPush(
1323 new CategoryMembershipChangeJob(
1326 'pageId' => $this->getPageId(),
1327 'revTimestamp' => $this->revision
->getTimestamp(),
1333 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1334 $editInfo = $this->getPreparedEdit();
1335 Hooks
::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $this->options
['changed'] ] );
1337 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1338 if ( Hooks
::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
1339 // Flush old entries from the `recentchanges` table
1340 if ( mt_rand( 0, 9 ) == 0 ) {
1341 $this->jobQueueGroup
->lazyPush( RecentChangesUpdateJob
::newPurgeJob() );
1345 $id = $this->getPageId();
1346 $title = $this->getTitle();
1347 $dbKey = $title->getPrefixedDBkey();
1348 $shortTitle = $title->getDBkey();
1350 if ( !$title->exists() ) {
1351 wfDebug( __METHOD__
. ": Page doesn't exist any more, bailing out\n" );
1353 $this->doTransition( 'done' );
1357 if ( $this->options
['oldcountable'] === 'no-change' ||
1358 ( !$this->options
['changed'] && !$this->options
['moved'] )
1361 } elseif ( $this->options
['created'] ) {
1362 $good = (int)$this->isCountable();
1363 } elseif ( $this->options
['oldcountable'] !== null ) {
1364 $good = (int)$this->isCountable()
1365 - (int)$this->options
['oldcountable'];
1366 } elseif ( isset( $this->pageState
['oldCountable'] ) ) {
1367 $good = (int)$this->isCountable()
1368 - (int)$this->pageState
['oldCountable'];
1372 $edits = $this->options
['changed'] ?
1 : 0;
1373 $pages = $this->options
['created'] ?
1 : 0;
1375 DeferredUpdates
::addUpdate( SiteStatsUpdate
::factory(
1376 [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1379 // TODO: make search infrastructure aware of slots!
1380 $mainSlot = $this->revision
->getSlot( 'main' );
1381 if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1382 DeferredUpdates
::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
1385 // If this is another user's talk page, update newtalk.
1386 // Don't do this if $options['changed'] = false (null-edits) nor if
1387 // it's a minor edit and the user making the edit doesn't generate notifications for those.
1388 if ( $this->options
['changed']
1389 && $title->getNamespace() == NS_USER_TALK
1390 && $shortTitle != $legacyUser->getTitleKey()
1391 && !( $this->revision
->isMinor() && $legacyUser->isAllowed( 'nominornewtalk' ) )
1393 $recipient = User
::newFromName( $shortTitle, false );
1394 if ( !$recipient ) {
1395 wfDebug( __METHOD__
. ": invalid username\n" );
1397 // Allow extensions to prevent user notification
1398 // when a new message is added to their talk page
1399 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1400 if ( Hooks
::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
1401 if ( User
::isIP( $shortTitle ) ) {
1402 // An anonymous user
1403 $recipient->setNewtalk( true, $legacyRevision );
1404 } elseif ( $recipient->isLoggedIn() ) {
1405 $recipient->setNewtalk( true, $legacyRevision );
1407 wfDebug( __METHOD__
. ": don't need to notify a nonexistent user\n" );
1413 if ( $title->getNamespace() == NS_MEDIAWIKI
1414 && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
1416 $mainContent = $this->isContentDeleted() ?
null : $this->getRawContent( 'main' );
1418 $this->messageCache
->updateMessageOverride( $title, $mainContent );
1421 // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
1422 if ( $this->options
['created'] ) {
1423 WikiPage
::onArticleCreate( $title );
1424 } elseif ( $this->options
['changed'] ) { // T52785
1425 WikiPage
::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
1428 $oldRevision = $this->getOldRevision();
1429 $oldLegacyRevision = $oldRevision ?
new Revision( $oldRevision ) : null;
1431 // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1432 ResourceLoaderWikiModule
::invalidateModuleCache(
1433 $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId() ?
: wfWikiID()
1436 $this->doTransition( 'done' );
1440 * Do secondary data updates (such as updating link tables).
1442 * MCR note: this method is temporarily exposed via WikiPage::doSecondaryDataUpdates.
1444 * @param array $options
1445 * - recursive: make the update recursive, i.e. also update pages which transclude the
1446 * current page or otherwise depend on it (default: false)
1447 * - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1448 * for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1449 * - transactionTicket: a transaction ticket from LBFactory::getEmptyTransactionTicket(),
1450 * only when defer is false (default: null)
1453 public function doSecondaryDataUpdates( array $options = [] ) {
1454 $this->assertHasRevision( __METHOD__
);
1456 'recursive' => false,
1458 'transactionTicket' => null,
1460 $deferValues = [ false, DeferredUpdates
::PRESEND
, DeferredUpdates
::POSTSEND
];
1461 if ( !in_array( $options['defer'], $deferValues, true ) ) {
1462 throw new InvalidArgumentException( 'invalid value for defer: ' . $options['defer'] );
1464 Assert
::parameterType( 'integer|null', $options['transactionTicket'],
1465 '$options[\'transactionTicket\']' );
1467 $updates = $this->getSecondaryDataUpdates( $options['recursive'] );
1469 $triggeringUser = $this->options
['triggeringUser'] ??
$this->user
;
1470 if ( !$triggeringUser instanceof User
) {
1471 $triggeringUser = User
::newFromIdentity( $triggeringUser );
1473 $causeAction = $this->options
['causeAction'] ??
'unknown';
1474 $causeAgent = $this->options
['causeAgent'] ??
'unknown';
1475 $legacyRevision = new Revision( $this->revision
);
1477 if ( $options['defer'] === false && $options['transactionTicket'] !== null ) {
1478 // For legacy hook handlers doing updates via LinksUpdateConstructed, make sure
1479 // any pending writes they made get flushed before the doUpdate() calls below.
1480 // This avoids snapshot-clearing errors in LinksUpdate::acquirePageLock().
1481 $this->loadbalancerFactory
->commitAndWaitForReplication(
1482 __METHOD__
, $options['transactionTicket']
1486 foreach ( $updates as $update ) {
1487 $update->setCause( $causeAction, $causeAgent );
1488 if ( $update instanceof LinksUpdate
) {
1489 $update->setRevision( $legacyRevision );
1490 $update->setTriggeringUser( $triggeringUser );
1492 if ( $options['defer'] === false ) {
1493 if ( $options['transactionTicket'] !== null ) {
1494 $update->setTransactionTicket( $options['transactionTicket'] );
1496 $update->doUpdate();
1498 DeferredUpdates
::addUpdate( $update, $options['defer'] );
1503 public function doParserCacheUpdate() {
1504 $this->assertHasRevision( __METHOD__
);
1506 $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1508 // NOTE: this may trigger the first parsing of the new content after an edit (when not
1509 // using pre-generated stashed output).
1510 // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1511 // to be performed post-send. The client could already follow a HTTP redirect to the
1512 // page view, but would then have to wait for a response until rendering is complete.
1513 $output = $this->getCanonicalParserOutput();
1515 // Save it to the parser cache. Use the revision timestamp in the case of a
1516 // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1517 // unnecessary reparse.
1518 $timestamp = $this->options
['changed'] ?
$this->revision
->getTimestamp()
1519 : $output->getTimestamp();
1520 $this->parserCache
->save(
1521 $output, $wikiPage, $this->getCanonicalParserOptions(),
1522 $timestamp, $this->revision
->getId()