MediaWiki REL1_34
DerivedPageDataUpdater.php
Go to the documentation of this file.
1<?php
23namespace MediaWiki\Storage;
24
26use Content;
28use DataUpdate;
31use Hooks;
33use InvalidArgumentException;
35use Language;
37use LinksUpdate;
38use LogicException;
50use MessageCache;
52use ParserCache;
54use ParserOutput;
55use Psr\Log\LoggerAwareInterface;
56use Psr\Log\LoggerInterface;
57use Psr\Log\NullLogger;
60use Revision;
61use SearchUpdate;
63use Title;
64use User;
65use Wikimedia\Assert\Assert;
67use WikiPage;
68
100class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface {
101
105 private $user = null;
106
110 private $wikiPage;
111
116
121
125 private $contLang;
126
131
136
141
145 private $logger;
146
151
156
161 private $options = [
162 'changed' => true,
163 // newrev is true if prepareUpdate is handling the creation of a new revision,
164 // as opposed to a null edit or a forced update.
165 'newrev' => false,
166 'created' => false,
167 'moved' => false,
168 'restored' => false,
169 'oldrevision' => null,
170 'oldcountable' => null,
171 'oldredirect' => null,
172 'triggeringUser' => null,
173 // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
174 // to make the life of prepareUpdate() callers easier.
175 'causeAction' => null,
176 'causeAgent' => null,
177 ];
178
198 private $pageState = null;
199
203 private $slotsUpdate = null;
204
208 private $parentRevision = null;
209
213 private $revision = null;
214
218 private $renderedRevision = null;
219
224
227
236 private $stage = 'new';
237
248 private static $transitions = [
249 'new' => [
250 'new' => true,
251 'knows-current' => true,
252 'has-content' => true,
253 'has-revision' => true,
254 ],
255 'knows-current' => [
256 'knows-current' => true,
257 'has-content' => true,
258 'has-revision' => true,
259 ],
260 'has-content' => [
261 'has-content' => true,
262 'has-revision' => true,
263 ],
264 'has-revision' => [
265 'has-revision' => true,
266 'done' => true,
267 ],
268 ];
269
281 public function __construct(
291 ) {
292 $this->wikiPage = $wikiPage;
293
294 $this->parserCache = $parserCache;
295 $this->revisionStore = $revisionStore;
296 $this->revisionRenderer = $revisionRenderer;
297 $this->slotRoleRegistry = $slotRoleRegistry;
298 $this->jobQueueGroup = $jobQueueGroup;
299 $this->messageCache = $messageCache;
300 $this->contLang = $contLang;
301 // XXX only needed for waiting for replicas to catch up; there should be a narrower
302 // interface for that.
303 $this->loadbalancerFactory = $loadbalancerFactory;
304 $this->logger = new NullLogger();
305 }
306
307 public function setLogger( LoggerInterface $logger ) {
308 $this->logger = $logger;
309 }
310
322 private function doTransition( $newStage ) {
323 $this->assertTransition( $newStage );
324
325 $oldStage = $this->stage;
326 $this->stage = $newStage;
327
328 return $oldStage;
329 }
330
340 private function assertTransition( $newStage ) {
341 if ( empty( self::$transitions[$this->stage][$newStage] ) ) {
342 throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
343 }
344 }
345
357 public function isReusableFor(
358 UserIdentity $user = null,
361 $parentId = null
362 ) {
363 if ( $revision
364 && $parentId
365 && $revision->getParentId() !== $parentId
366 ) {
367 throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
368 }
369
370 // NOTE: For null revisions, $user may be different from $this->revision->getUser
371 // and also from $revision->getUser.
372 // But $user should always match $this->user.
373 if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
374 return false;
375 }
376
377 if ( $revision && $this->revision && $this->revision->getId()
378 && $this->revision->getId() !== $revision->getId()
379 ) {
380 return false;
381 }
382
383 if ( $this->pageState
384 && $revision
385 && $revision->getParentId() !== null
386 && $this->pageState['oldId'] !== $revision->getParentId()
387 ) {
388 return false;
389 }
390
391 if ( $this->pageState
392 && $parentId !== null
393 && $this->pageState['oldId'] !== $parentId
394 ) {
395 return false;
396 }
397
398 // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
399 if ( $this->slotsUpdate
400 && $slotsUpdate
401 && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
402 ) {
403 return false;
404 }
405
406 if ( $revision
407 && $this->revision
408 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
409 ) {
410 return false;
411 }
412
413 return true;
414 }
415
421 $this->articleCountMethod = $articleCountMethod;
422 }
423
429 $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
430 }
431
435 private function getTitle() {
436 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
437 return $this->wikiPage->getTitle();
438 }
439
443 private function getWikiPage() {
444 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
445 return $this->wikiPage;
446 }
447
455 public function pageExisted() {
456 $this->assertHasPageState( __METHOD__ );
457
458 return $this->pageState['oldId'] > 0;
459 }
460
470 private function getParentRevision() {
471 $this->assertPrepared( __METHOD__ );
472
473 if ( $this->parentRevision ) {
475 }
476
477 if ( !$this->pageState['oldId'] ) {
478 // If there was no current revision, there is no parent revision,
479 // since the page didn't exist.
480 return null;
481 }
482
483 $oldId = $this->revision->getParentId();
484 $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
485 $this->parentRevision = $oldId
486 ? $this->revisionStore->getRevisionById( $oldId, $flags )
487 : null;
488
490 }
491
512 public function grabCurrentRevision() {
513 if ( $this->pageState ) {
514 return $this->pageState['oldRevision'];
515 }
516
517 $this->assertTransition( 'knows-current' );
518
519 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
520 $wikiPage = $this->getWikiPage();
521
522 // Do not call WikiPage::clear(), since the caller may already have caused page data
523 // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
524 $wikiPage->loadPageData( self::READ_LATEST );
525 $rev = $wikiPage->getRevision();
526 $current = $rev ? $rev->getRevisionRecord() : null;
527
528 $this->pageState = [
529 'oldRevision' => $current,
530 'oldId' => $rev ? $rev->getId() : 0,
531 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
532 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
533 ];
534
535 $this->doTransition( 'knows-current' );
536
537 return $this->pageState['oldRevision'];
538 }
539
545 public function isContentPrepared() {
546 return $this->revision !== null;
547 }
548
556 public function isUpdatePrepared() {
557 return $this->revision !== null && $this->revision->getId() !== null;
558 }
559
563 private function getPageId() {
564 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
565 return $this->wikiPage->getId();
566 }
567
573 public function isContentDeleted() {
574 if ( $this->revision ) {
575 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
576 } else {
577 // If the content has not been saved yet, it cannot have been deleted yet.
578 return false;
579 }
580 }
581
591 public function getRawSlot( $role ) {
592 return $this->getSlots()->getSlot( $role );
593 }
594
603 public function getRawContent( $role ) {
604 return $this->getRawSlot( $role )->getContent();
605 }
606
613 private function getContentModel( $role ) {
614 return $this->getRawSlot( $role )->getModel();
615 }
616
621 private function getContentHandler( $role ) {
622 // TODO: inject something like a ContentHandlerRegistry
623 return ContentHandler::getForModelID( $this->getContentModel( $role ) );
624 }
625
626 private function useMaster() {
627 // TODO: can we just set a flag to true in prepareContent()?
628 return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
629 }
630
634 public function isCountable() {
635 // NOTE: Keep in sync with WikiPage::isCountable.
636
637 if ( !$this->getTitle()->isContentPage() ) {
638 return false;
639 }
640
641 if ( $this->isContentDeleted() ) {
642 // This should be irrelevant: countability only applies to the current revision,
643 // and the current revision is never suppressed.
644 return false;
645 }
646
647 if ( $this->isRedirect() ) {
648 return false;
649 }
650
651 $hasLinks = null;
652
653 if ( $this->articleCountMethod === 'link' ) {
654 // NOTE: it would be more appropriate to determine for each slot separately
655 // whether it has links, and use that information with that slot's
656 // isCountable() method. However, that would break parity with
657 // WikiPage::isCountable, which uses the pagelinks table to determine
658 // whether the current revision has links.
659 $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
660 }
661
662 foreach ( $this->getSlots()->getSlotRoles() as $role ) {
663 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
664 if ( $roleHandler->supportsArticleCount() ) {
665 $content = $this->getRawContent( $role );
666
667 if ( $content->isCountable( $hasLinks ) ) {
668 return true;
669 }
670 }
671 }
672
673 return false;
674 }
675
679 public function isRedirect() {
680 // NOTE: main slot determines redirect status
681 // TODO: MCR: this should be controlled by a PageTypeHandler
682 $mainContent = $this->getRawContent( SlotRecord::MAIN );
683
684 return $mainContent->isRedirect();
685 }
686
692 private function revisionIsRedirect( RevisionRecord $rev ) {
693 // NOTE: main slot determines redirect status
694 $mainContent = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
695
696 return $mainContent->isRedirect();
697 }
698
723 public function prepareContent(
724 User $user,
726 $useStash = true
727 ) {
728 if ( $this->slotsUpdate ) {
729 if ( !$this->user ) {
730 throw new LogicException(
731 'Unexpected state: $this->slotsUpdate was initialized, '
732 . 'but $this->user was not.'
733 );
734 }
735
736 if ( $this->user->getName() !== $user->getName() ) {
737 throw new LogicException( 'Can\'t call prepareContent() again for different user! '
738 . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
739 );
740 }
741
742 if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
743 throw new LogicException(
744 'Can\'t call prepareContent() again with different slot content!'
745 );
746 }
747
748 return; // prepareContent() already done, nothing to do
749 }
750
751 $this->assertTransition( 'has-content' );
752
753 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
754 $title = $this->getTitle();
755
757
758 // The edit may have already been prepared via api.php?action=stashedit
759 $stashedEdit = false;
760
761 // TODO: MCR: allow output for all slots to be stashed.
762 if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
763 $editStash = MediaWikiServices::getInstance()->getPageEditStash();
764 $stashedEdit = $editStash->checkCache(
765 $title,
766 $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
768 );
769 }
770
771 $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
772 Hooks::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
773
774 $this->user = $user;
775 $this->slotsUpdate = $slotsUpdate;
776
777 if ( $parentRevision ) {
778 $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
779 } else {
780 $this->revision = new MutableRevisionRecord( $title );
781 }
782
783 // NOTE: user and timestamp must be set, so they can be used for
784 // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
785 $this->revision->setTimestamp( wfTimestampNow() );
786 $this->revision->setUser( $user );
787
788 // Set up ParserOptions to operate on the new revision
789 $oldCallback = $userPopts->getCurrentRevisionCallback();
790 $userPopts->setCurrentRevisionCallback(
791 function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
792 if ( $parserTitle->equals( $title ) ) {
793 $legacyRevision = new Revision( $this->revision );
794 return $legacyRevision;
795 } else {
796 return call_user_func( $oldCallback, $parserTitle, $parser );
797 }
798 }
799 );
800
801 $pstContentSlots = $this->revision->getSlots();
802
803 foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
804 $slot = $slotsUpdate->getModifiedSlot( $role );
805
806 if ( $slot->isInherited() ) {
807 // No PST for inherited slots! Note that "modified" slots may still be inherited
808 // from an earlier version, e.g. for rollbacks.
809 $pstSlot = $slot;
810 } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
811 // TODO: MCR: allow PST content for all slots to be stashed.
812 $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
813 } else {
814 $content = $slot->getContent();
815 $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
816 $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
817 }
818
819 $pstContentSlots->setSlot( $pstSlot );
820 }
821
822 foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
823 $pstContentSlots->removeSlot( $role );
824 }
825
826 $this->options['created'] = ( $parentRevision === null );
827 $this->options['changed'] = ( $parentRevision === null
828 || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
829
830 $this->doTransition( 'has-content' );
831
832 if ( !$this->options['changed'] ) {
833 // null-edit!
834
835 // TODO: move this into MutableRevisionRecord
836 // TODO: This needs to behave differently for a forced dummy edit!
837 $this->revision->setId( $parentRevision->getId() );
838 $this->revision->setTimestamp( $parentRevision->getTimestamp() );
839 $this->revision->setPageId( $parentRevision->getPageId() );
840 $this->revision->setParentId( $parentRevision->getParentId() );
841 $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
842 $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
843 $this->revision->setMinorEdit( $parentRevision->isMinor() );
844 $this->revision->setVisibility( $parentRevision->getVisibility() );
845
846 // prepareUpdate() is redundant for null-edits
847 $this->doTransition( 'has-revision' );
848 } else {
849 $this->parentRevision = $parentRevision;
850 }
851
852 $renderHints = [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ];
853
854 if ( $stashedEdit ) {
856 $output = $stashedEdit->output;
857 // TODO: this should happen when stashing the ParserOutput, not now!
858 $output->setCacheTime( $stashedEdit->timestamp );
859
860 $renderHints['known-revision-output'] = $output;
861
862 $this->logger->debug( __METHOD__ . ': using stashed edit output...' );
863 }
864
865 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
866 // NOTE: the revision is either new or current, so we can bypass audience checks.
867 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
868 $this->revision,
869 null,
870 null,
871 $renderHints
872 );
873 }
874
890 public function getRevision() {
891 $this->assertPrepared( __METHOD__ );
892 return $this->revision;
893 }
894
898 public function getRenderedRevision() {
899 $this->assertPrepared( __METHOD__ );
900
902 }
903
904 private function assertHasPageState( $method ) {
905 if ( !$this->pageState ) {
906 throw new LogicException(
907 'Must call grabCurrentRevision() or prepareContent() '
908 . 'or prepareUpdate() before calling ' . $method
909 );
910 }
911 }
912
913 private function assertPrepared( $method ) {
914 if ( !$this->revision ) {
915 throw new LogicException(
916 'Must call prepareContent() or prepareUpdate() before calling ' . $method
917 );
918 }
919 }
920
921 private function assertHasRevision( $method ) {
922 if ( !$this->revision->getId() ) {
923 throw new LogicException(
924 'Must call prepareUpdate() before calling ' . $method
925 );
926 }
927 }
928
934 public function isCreation() {
935 $this->assertPrepared( __METHOD__ );
936 return $this->options['created'];
937 }
938
948 public function isChange() {
949 $this->assertPrepared( __METHOD__ );
950 return $this->options['changed'];
951 }
952
958 public function wasRedirect() {
959 $this->assertHasPageState( __METHOD__ );
960
961 if ( $this->pageState['oldIsRedirect'] === null ) {
963 $rev = $this->pageState['oldRevision'];
964 if ( $rev ) {
965 $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
966 } else {
967 $this->pageState['oldIsRedirect'] = false;
968 }
969 }
970
971 return $this->pageState['oldIsRedirect'];
972 }
973
982 public function getSlots() {
983 $this->assertPrepared( __METHOD__ );
984 return $this->revision->getSlots();
985 }
986
992 private function getRevisionSlotsUpdate() {
993 $this->assertPrepared( __METHOD__ );
994
995 if ( !$this->slotsUpdate ) {
996 $old = $this->getParentRevision();
998 $this->revision->getSlots(),
999 $old ? $old->getSlots() : null
1000 );
1001 }
1002 return $this->slotsUpdate;
1003 }
1004
1011 public function getTouchedSlotRoles() {
1012 return $this->getRevisionSlotsUpdate()->getTouchedRoles();
1013 }
1014
1021 public function getModifiedSlotRoles() {
1022 return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1023 }
1024
1030 public function getRemovedSlotRoles() {
1031 return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1032 }
1033
1082 public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1083 Assert::parameter(
1084 !isset( $options['oldrevision'] )
1085 || $options['oldrevision'] instanceof Revision
1086 || $options['oldrevision'] instanceof RevisionRecord,
1087 '$options["oldrevision"]',
1088 'must be a RevisionRecord (or Revision)'
1089 );
1090 Assert::parameter(
1091 !isset( $options['triggeringUser'] )
1092 || $options['triggeringUser'] instanceof UserIdentity,
1093 '$options["triggeringUser"]',
1094 'must be a UserIdentity'
1095 );
1096
1097 if ( !$revision->getId() ) {
1098 throw new InvalidArgumentException(
1099 'Revision must have an ID set for it to be used with prepareUpdate()!'
1100 );
1101 }
1102
1103 if ( $this->revision && $this->revision->getId() ) {
1104 if ( $this->revision->getId() === $revision->getId() ) {
1105 return; // nothing to do!
1106 } else {
1107 throw new LogicException(
1108 'Trying to re-use DerivedPageDataUpdater with revision '
1109 . $revision->getId()
1110 . ', but it\'s already bound to revision '
1111 . $this->revision->getId()
1112 );
1113 }
1114 }
1115
1116 if ( $this->revision
1117 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1118 ) {
1119 throw new LogicException(
1120 'The Revision provided has mismatching content!'
1121 );
1122 }
1123
1124 // Override fields defined in $this->options with values from $options.
1125 $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1126
1127 if ( $this->revision ) {
1128 $oldId = $this->pageState['oldId'] ?? 0;
1129 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1130 } elseif ( isset( $this->options['oldrevision'] ) ) {
1132 $oldRev = $this->options['oldrevision'];
1133 $oldId = $oldRev->getId();
1134 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1135 } else {
1136 $oldId = $revision->getParentId();
1137 }
1138
1139 if ( $oldId !== null ) {
1140 // XXX: what if $options['changed'] disagrees?
1141 // MovePage creates a dummy revision with changed = false!
1142 // We may want to explicitly distinguish between "no new revision" (null-edit)
1143 // and "new revision without new content" (dummy revision).
1144
1145 if ( $oldId === $revision->getParentId() ) {
1146 // NOTE: this may still be a NullRevision!
1147 // New revision!
1148 $this->options['changed'] = true;
1149 } elseif ( $oldId === $revision->getId() ) {
1150 // Null-edit!
1151 $this->options['changed'] = false;
1152 } else {
1153 // This indicates that calling code has given us the wrong Revision object
1154 throw new LogicException(
1155 'The Revision mismatches old revision ID: '
1156 . 'Old ID is ' . $oldId
1157 . ', parent ID is ' . $revision->getParentId()
1158 . ', revision ID is ' . $revision->getId()
1159 );
1160 }
1161 }
1162
1163 // If prepareContent() was used to generate the PST content (which is indicated by
1164 // $this->slotsUpdate being set), and this is not a null-edit, then the given
1165 // revision must have the acting user as the revision author. Otherwise, user
1166 // signatures generated by PST would mismatch the user in the revision record.
1167 if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1168 $user = $revision->getUser();
1169 if ( !$this->user->equals( $user ) ) {
1170 throw new LogicException(
1171 'The Revision provided has a mismatching actor: expected '
1172 . $this->user->getName()
1173 . ', got '
1174 . $user->getName()
1175 );
1176 }
1177 }
1178
1179 // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1180 // emulate the state of the page table before the edit, as good as we can.
1181 if ( !$this->pageState ) {
1182 $this->pageState = [
1183 'oldIsRedirect' => isset( $this->options['oldredirect'] )
1184 && is_bool( $this->options['oldredirect'] )
1185 ? $this->options['oldredirect']
1186 : null,
1187 'oldCountable' => isset( $this->options['oldcountable'] )
1188 && is_bool( $this->options['oldcountable'] )
1189 ? $this->options['oldcountable']
1190 : null,
1191 ];
1192
1193 if ( $this->options['changed'] ) {
1194 // The edit created a new revision
1195 $this->pageState['oldId'] = $revision->getParentId();
1196
1197 if ( isset( $this->options['oldrevision'] ) ) {
1198 $rev = $this->options['oldrevision'];
1199 $this->pageState['oldRevision'] = $rev instanceof Revision
1200 ? $rev->getRevisionRecord()
1201 : $rev;
1202 }
1203 } else {
1204 // This is a null-edit, so the old revision IS the new revision!
1205 $this->pageState['oldId'] = $revision->getId();
1206 $this->pageState['oldRevision'] = $revision;
1207 }
1208 }
1209
1210 // "created" is forced here
1211 $this->options['created'] = ( $this->options['created'] ||
1212 ( $this->pageState['oldId'] === 0 ) );
1213
1214 $this->revision = $revision;
1215
1216 $this->doTransition( 'has-revision' );
1217
1218 // NOTE: in case we have a User object, don't override with a UserIdentity.
1219 // We already checked that $revision->getUser() mathces $this->user;
1220 if ( !$this->user ) {
1221 $this->user = $revision->getUser( RevisionRecord::RAW );
1222 }
1223
1224 // Prune any output that depends on the revision ID.
1225 if ( $this->renderedRevision ) {
1226 $this->renderedRevision->updateRevision( $revision );
1227 } else {
1228 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1229 // NOTE: the revision is either new or current, so we can bypass audience checks.
1230 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1231 $this->revision,
1232 null,
1233 null,
1234 [
1235 'use-master' => $this->useMaster(),
1236 'audience' => RevisionRecord::RAW,
1237 'known-revision-output' => $options['known-revision-output'] ?? null
1238 ]
1239 );
1240
1241 // XXX: Since we presumably are dealing with the current revision,
1242 // we could try to get the ParserOutput from the parser cache.
1243 }
1244
1245 // TODO: optionally get ParserOutput from the ParserCache here.
1246 // Move the logic used by RefreshLinksJob here!
1247 }
1248
1253 public function getPreparedEdit() {
1254 $this->assertPrepared( __METHOD__ );
1255
1257 $preparedEdit = new PreparedEdit();
1258
1259 $preparedEdit->popts = $this->getCanonicalParserOptions();
1260 $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
1261 $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
1262 $preparedEdit->newContent =
1263 $slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
1264 ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent()
1265 : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this?
1266 $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1267 $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1268 $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
1269 $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1270
1271 return $preparedEdit;
1272 }
1273
1279 public function getSlotParserOutput( $role, $generateHtml = true ) {
1280 return $this->getRenderedRevision()->getSlotParserOutput(
1281 $role,
1282 [ 'generate-html' => $generateHtml ]
1283 );
1284 }
1285
1289 public function getCanonicalParserOutput() {
1290 return $this->getRenderedRevision()->getRevisionParserOutput();
1291 }
1292
1296 public function getCanonicalParserOptions() {
1297 return $this->getRenderedRevision()->getOptions();
1298 }
1299
1305 public function getSecondaryDataUpdates( $recursive = false ) {
1306 if ( $this->isContentDeleted() ) {
1307 // This shouldn't happen, since the current content is always public,
1308 // and DataUpates are only needed for current content.
1309 return [];
1310 }
1311
1312 $output = $this->getCanonicalParserOutput();
1313
1314 // Construct a LinksUpdate for the combined canonical output.
1315 $linksUpdate = new LinksUpdate(
1316 $this->getTitle(),
1317 $output,
1318 $recursive
1319 );
1320
1321 $allUpdates = [ $linksUpdate ];
1322
1323 // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
1324 // info for an inherited slot may end up being removed. This is also needed
1325 // to ensure that purges are effective.
1327 foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1328 $slot = $this->getRawSlot( $role );
1329 $content = $slot->getContent();
1330 $handler = $content->getContentHandler();
1331
1332 $updates = $handler->getSecondaryDataUpdates(
1333 $this->getTitle(),
1334 $content,
1335 $role,
1337 );
1338 $allUpdates = array_merge( $allUpdates, $updates );
1339
1340 // TODO: remove B/C hack in 1.32!
1341 // NOTE: we assume that the combined output contains all relevant meta-data for
1342 // all slots!
1343 $legacyUpdates = $content->getSecondaryDataUpdates(
1344 $this->getTitle(),
1345 null,
1346 $recursive,
1347 $output
1348 );
1349
1350 // HACK: filter out redundant and incomplete LinksUpdates
1351 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1352 return !( $update instanceof LinksUpdate );
1353 } );
1354
1355 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1356 }
1357
1358 // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
1359 // that time, we don't know for which slots to run deletion updates when purging a page.
1360 // We'd have to examine the entire history of the page to determine that. Perhaps there
1361 // could be a "try extra hard" mode for that case that would run a DB query to find all
1362 // roles/models ever used on the page. On the other hand, removing slots should be quite
1363 // rare, so perhaps this isn't worth the trouble.
1364
1365 // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
1366 $wikiPage = $this->getWikiPage();
1368 foreach ( $this->getRemovedSlotRoles() as $role ) {
1369 // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
1370 // For now, find the slot in the parent revision - if the slot was removed, it should
1371 // always exist in the parent revision.
1372 $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
1373 $content = $parentSlot->getContent();
1374 $handler = $content->getContentHandler();
1375
1376 $updates = $handler->getDeletionUpdates(
1377 $this->getTitle(),
1378 $role
1379 );
1380 $allUpdates = array_merge( $allUpdates, $updates );
1381
1382 // TODO: remove B/C hack in 1.32!
1383 $legacyUpdates = $content->getDeletionUpdates( $wikiPage );
1384
1385 // HACK: filter out redundant and incomplete LinksDeletionUpdate
1386 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1387 return !( $update instanceof LinksDeletionUpdate );
1388 } );
1389
1390 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1391 }
1392
1393 // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
1394 Hooks::run(
1395 'RevisionDataUpdates',
1396 [ $this->getTitle(), $renderedRevision, &$allUpdates ]
1397 );
1398
1399 return $allUpdates;
1400 }
1401
1412 public function doUpdates() {
1413 $this->assertTransition( 'done' );
1414
1415 // TODO: move logic into a PageEventEmitter service
1416
1417 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1418
1419 $legacyUser = User::newFromIdentity( $this->user );
1420 $legacyRevision = new Revision( $this->revision );
1421
1422 $userParserOptions = ParserOptions::newFromUser( $legacyUser );
1423 // Decide whether to save the final canonical parser ouput based on the fact that
1424 // users are typically redirected to viewing pages right after they edit those pages.
1425 // Due to vary-revision-id, getting/saving that output here might require a reparse.
1426 if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
1427 // Whether getting the final output requires a reparse or not, the user will
1428 // need canonical output anyway, since that is what their parser options use.
1429 // A reparse now at least has the benefit of various warm process caches.
1430 $this->doParserCacheUpdate();
1431 } else {
1432 // If the user does not have canonical parse options, then don't risk another parse
1433 // to make output they cannot use on the page refresh that typically occurs after
1434 // editing. Doing the parser output save post-send will still benefit *other* users.
1435 DeferredUpdates::addCallableUpdate( function () {
1436 $this->doParserCacheUpdate();
1437 } );
1438 }
1439
1440 // Defer the getCannonicalParserOutput() call triggered by getSecondaryDataUpdates()
1441 // by wrapping the code that schedules the secondary updates in a callback itself
1442 $wrapperUpdate = new MWCallableUpdate(
1443 function () {
1444 $this->doSecondaryDataUpdates( [
1445 // T52785 do not update any other pages on a null edit
1446 'recursive' => $this->options['changed']
1447 ] );
1448 },
1449 __METHOD__
1450 );
1451 $wrapperUpdate->setTransactionRoundRequirement( $wrapperUpdate::TRX_ROUND_ABSENT );
1452 DeferredUpdates::addUpdate( $wrapperUpdate );
1453
1454 // TODO: MCR: check if *any* changed slot supports categories!
1455 if ( $this->rcWatchCategoryMembership
1456 && $this->getContentHandler( SlotRecord::MAIN )->supportsCategories() === true
1457 && ( $this->options['changed'] || $this->options['created'] )
1458 && !$this->options['restored']
1459 ) {
1460 // Note: jobs are pushed after deferred updates, so the job should be able to see
1461 // the recent change entry (also done via deferred updates) and carry over any
1462 // bot/deletion/IP flags, ect.
1463 $this->jobQueueGroup->lazyPush(
1465 $this->getTitle(),
1466 $this->revision->getTimestamp()
1467 )
1468 );
1469 }
1470
1471 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1472 // @note: Extensions should *avoid* calling getCannonicalParserOutput() when using
1473 // this hook whenever possible in order to avoid unnecessary additional parses.
1474 $editInfo = $this->getPreparedEdit();
1475 Hooks::run( 'ArticleEditUpdates',
1476 [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
1477
1478 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1479 if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
1480 // Flush old entries from the `recentchanges` table
1481 if ( mt_rand( 0, 9 ) == 0 ) {
1482 $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
1483 }
1484 }
1485
1486 $id = $this->getPageId();
1487 $title = $this->getTitle();
1488 $shortTitle = $title->getDBkey();
1489
1490 if ( !$title->exists() ) {
1491 wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out\n" );
1492
1493 $this->doTransition( 'done' );
1494 return;
1495 }
1496
1497 DeferredUpdates::addCallableUpdate( function () {
1498 if (
1499 $this->options['oldcountable'] === 'no-change' ||
1500 ( !$this->options['changed'] && !$this->options['moved'] )
1501 ) {
1502 $good = 0;
1503 } elseif ( $this->options['created'] ) {
1504 $good = (int)$this->isCountable();
1505 } elseif ( $this->options['oldcountable'] !== null ) {
1506 $good = (int)$this->isCountable()
1507 - (int)$this->options['oldcountable'];
1508 } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1509 $good = (int)$this->isCountable()
1510 - (int)$this->pageState['oldCountable'];
1511 } else {
1512 $good = 0;
1513 }
1514 $edits = $this->options['changed'] ? 1 : 0;
1515 $pages = $this->options['created'] ? 1 : 0;
1516
1517 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1518 [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1519 ) );
1520 } );
1521
1522 // TODO: make search infrastructure aware of slots!
1523 $mainSlot = $this->revision->getSlot( SlotRecord::MAIN );
1524 if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1525 DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $mainSlot->getContent() ) );
1526 }
1527
1528 // If this is another user's talk page, update newtalk.
1529 // Don't do this if $options['changed'] = false (null-edits) nor if
1530 // it's a minor edit and the user making the edit doesn't generate notifications for those.
1531 if ( $this->options['changed']
1532 && $title->getNamespace() == NS_USER_TALK
1533 && $shortTitle != $legacyUser->getTitleKey()
1534 && !( $this->revision->isMinor() && MediaWikiServices::getInstance()
1535 ->getPermissionManager()
1536 ->userHasRight( $legacyUser, 'nominornewtalk' ) )
1537 ) {
1538 $recipient = User::newFromName( $shortTitle, false );
1539 if ( !$recipient ) {
1540 wfDebug( __METHOD__ . ": invalid username\n" );
1541 } else {
1542 // Allow extensions to prevent user notification
1543 // when a new message is added to their talk page
1544 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1545 if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
1546 if ( User::isIP( $shortTitle ) ) {
1547 // An anonymous user
1548 $recipient->setNewtalk( true, $legacyRevision );
1549 } elseif ( $recipient->isLoggedIn() ) {
1550 $recipient->setNewtalk( true, $legacyRevision );
1551 } else {
1552 wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
1553 }
1554 }
1555 }
1556 }
1557
1558 if ( $title->getNamespace() == NS_MEDIAWIKI
1559 && $this->getRevisionSlotsUpdate()->isModifiedSlot( SlotRecord::MAIN )
1560 ) {
1561 $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( SlotRecord::MAIN );
1562
1563 $this->messageCache->updateMessageOverride( $title, $mainContent );
1564 }
1565
1566 // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
1567 if ( $this->options['created'] ) {
1568 WikiPage::onArticleCreate( $title );
1569 } elseif ( $this->options['changed'] ) { // T52785
1570 WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
1571 }
1572
1573 $oldRevision = $this->getParentRevision();
1574 $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
1575
1576 // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1578 $title,
1579 $oldLegacyRevision,
1580 $legacyRevision,
1581 $this->loadbalancerFactory->getLocalDomainID()
1582 );
1583
1584 $this->doTransition( 'done' );
1585 }
1586
1599 public function doSecondaryDataUpdates( array $options = [] ) {
1600 $this->assertHasRevision( __METHOD__ );
1601 $options += [ 'recursive' => false, 'defer' => false ];
1602 $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1603 if ( !in_array( $options['defer'], $deferValues, true ) ) {
1604 throw new InvalidArgumentException( 'Invalid value for defer: ' . $options['defer'] );
1605 }
1606 $updates = $this->getSecondaryDataUpdates( $options['recursive'] );
1607
1608 $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1609 if ( !$triggeringUser instanceof User ) {
1610 $triggeringUser = User::newFromIdentity( $triggeringUser );
1611 }
1612 $causeAction = $this->options['causeAction'] ?? 'unknown';
1613 $causeAgent = $this->options['causeAgent'] ?? 'unknown';
1614 $legacyRevision = new Revision( $this->revision );
1615
1616 foreach ( $updates as $update ) {
1617 if ( $update instanceof DataUpdate ) {
1618 $update->setCause( $causeAction, $causeAgent );
1619 }
1620 if ( $update instanceof LinksUpdate ) {
1621 $update->setRevision( $legacyRevision );
1622 $update->setTriggeringUser( $triggeringUser );
1623 }
1624 }
1625
1626 if ( $options['defer'] === false ) {
1627 // T221577: flush any transaction; each update needs outer transaction scope
1628 $this->loadbalancerFactory->commitMasterChanges( __METHOD__ );
1629 foreach ( $updates as $update ) {
1630 DeferredUpdates::attemptUpdate( $update, $this->loadbalancerFactory );
1631 }
1632 } else {
1633 foreach ( $updates as $update ) {
1634 DeferredUpdates::addUpdate( $update, $options['defer'] );
1635 }
1636 }
1637 }
1638
1639 public function doParserCacheUpdate() {
1640 $this->assertHasRevision( __METHOD__ );
1641
1642 $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1643
1644 // NOTE: this may trigger the first parsing of the new content after an edit (when not
1645 // using pre-generated stashed output).
1646 // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1647 // to be performed post-send. The client could already follow a HTTP redirect to the
1648 // page view, but would then have to wait for a response until rendering is complete.
1649 $output = $this->getCanonicalParserOutput();
1650
1651 // Save it to the parser cache. Use the revision timestamp in the case of a
1652 // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1653 // unnecessary reparse.
1654 $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp()
1655 : $output->getCacheTime();
1656 $this->parserCache->save(
1657 $output, $wikiPage, $this->getCanonicalParserOptions(),
1658 $timestamp, $this->revision->getId()
1659 );
1660 }
1661
1662}
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
Job to add recent change entries mentioning category membership changes.
static newSpec(Title $title, $revisionTimestamp)
A content handler knows how do deal with a specific type of content on a wiki page.
Abstract base class for update jobs that do something with some secondary data extracted from article...
Class for managing the deferred updates.
Hooks class.
Definition Hooks.php:34
Class to handle enqueueing of background jobs.
Internationalisation code.
Definition Language.php:37
Update object handling the cleanup of links tables after a page was deleted.
Class the manages updates of *_link tables as well as similar extension-managed tables.
Deferrable Update for closure/callback.
setTransactionRoundRequirement( $mode)
Represents information returned by WikiPage::prepareContentForEdit()
MediaWikiServices is the service locator for the application scope of MediaWiki.
static getInstance()
Returns the global default instance of the top level service locator.
RenderedRevision represents the rendered representation of a revision.
Page revision base class.
getParentId()
Get parent revision ID (the original previous page revision).
getComment( $audience=self::FOR_PUBLIC, User $user=null)
Fetch revision comment, if it's available to the specified audience.
getVisibility()
Get the deletion bitfield of the revision.
getSlots()
Returns the slots defined for this revision.
getContent( $role, $audience=self::FOR_PUBLIC, User $user=null)
Returns the Content of the given slot of this revision.
getTimestamp()
MCR migration note: this replaces Revision::getTimestamp.
getSlot( $role, $audience=self::FOR_PUBLIC, User $user=null)
Returns meta-data for the given slot.
isMinor()
MCR migration note: this replaces Revision::isMinor.
getUser( $audience=self::FOR_PUBLIC, User $user=null)
Fetch revision's author's user identity, if it's available to the specified audience.
The RevisionRenderer service provides access to rendered output for revisions.
Value object representing the set of slots belonging to a revision.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page.
A handle for managing updates for derived page data on edit, import, purge, etc.
getModifiedSlotRoles()
Returns the role names of the slots modified by the new revision, not including removed roles.
setRcWatchCategoryMembership( $rcWatchCategoryMembership)
static array[] $transitions
Transition table for managing the life cycle of DerivedPageDateUpdater instances.
string $stage
A stage identifier for managing the life cycle of this instance.
getParentRevision()
Returns the parent revision of the new revision wrapped by this update.
array $pageState
The state of the relevant row in page table before the edit.
doSecondaryDataUpdates(array $options=[])
Do secondary data updates (such as updating link tables).
isContentDeleted()
Whether the content is deleted and thus not visible to the public.
prepareUpdate(RevisionRecord $revision, array $options=[])
Prepare derived data updates targeting the given Revision.
string $articleCountMethod
see $wgArticleCountMethod
doTransition( $newStage)
Transition function for managing the life cycle of this instances.
isCreation()
Whether the edit creates the page.
__construct(WikiPage $wikiPage, RevisionStore $revisionStore, RevisionRenderer $revisionRenderer, SlotRoleRegistry $slotRoleRegistry, ParserCache $parserCache, JobQueueGroup $jobQueueGroup, MessageCache $messageCache, Language $contLang, ILBFactory $loadbalancerFactory)
getRemovedSlotRoles()
Returns the role names of the slots removed by the new revision.
getRawContent( $role)
Returns the content of the given slot, with no audience checks.
getRevisionSlotsUpdate()
Returns the RevisionSlotsUpdate for this updater.
isChange()
Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
grabCurrentRevision()
Returns the revision that was the page's current revision when grabCurrentRevision() was first called...
doUpdates()
Do standard updates after page edit, purge, or import.
pageExisted()
Determines whether the page being edited already existed.
wasRedirect()
Whether the page was a redirect before the edit.
isUpdatePrepared()
Whether prepareUpdate() has been called on this instance.
getContentModel( $role)
Returns the content model of the given slot.
boolean $rcWatchCategoryMembership
see $wgRCWatchCategoryMembership
getSlots()
Returns the slots of the target revision, after PST.
isReusableFor(UserIdentity $user=null, RevisionRecord $revision=null, RevisionSlotsUpdate $slotsUpdate=null, $parentId=null)
Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting the given rev...
getTouchedSlotRoles()
Returns the role names of the slots touched by the new revision, including removed roles.
isContentPrepared()
Whether prepareUpdate() or prepareContent() have been called on this instance.
getRawSlot( $role)
Returns the slot, modified or inherited, after PST, with no audience checks applied.
$options
Stores (most of) the $options parameter of prepareUpdate().
assertTransition( $newStage)
Asserts that a transition to the given stage is possible, without performing it.
getRevision()
Returns the update's target revision - that is, the revision that will be the current revision after ...
prepareContent(User $user, RevisionSlotsUpdate $slotsUpdate, $useStash=true)
Prepare updates based on an update which has not yet been saved.
Value object representing a modification of revision slots.
getRemovedRoles()
Returns a list of removed slot roles, that is, roles removed by calling removeSlot(),...
hasSameUpdates(RevisionSlotsUpdate $other)
Returns true if $other represents the same update - that is, if all methods defined by RevisionSlotsU...
getModifiedRoles()
Returns a list of modified slot roles, that is, roles modified by calling modifySlot(),...
getModifiedSlot( $role)
Returns the SlotRecord associated with the given role, if the slot with that role was modified (and n...
static newFromRevisionSlots(RevisionSlots $newSlots, RevisionSlots $parentSlots=null)
Constructs a RevisionSlotsUpdate representing the update that turned $parentSlots into $newSlots.
isModifiedSlot( $role)
Returns whether getModifiedSlot() will return a SlotRecord for the given role.
Cache of messages that are defined by MediaWiki namespace pages or by hooks.
Set options of the Parser.
Job for pruning recent changes.
Abstraction for ResourceLoader modules which pull from wiki pages.
static invalidateModuleCache(Title $title, Revision $old=null, Revision $new=null, $domain)
Clear the preloadTitleInfo() cache for all wiki modules on this wiki on page change if it was a JS or...
getRevisionRecord()
Definition Revision.php:433
Database independant search index updater.
Class for handling updates to the site_stats table.
static factory(array $deltas)
Represents a title within MediaWiki.
Definition Title.php:42
equals(LinkTarget $title)
Compare with another title.
Definition Title.php:4113
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:51
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition User.php:518
static newFromIdentity(UserIdentity $identity)
Returns a User object corresponding to the given UserIdentity.
Definition User.php:574
static isIP( $name)
Does the string match an anonymous IP address?
Definition User.php:933
Class representing a MediaWiki article and history.
Definition WikiPage.php:47
getRevision()
Get the latest revision.
Definition WikiPage.php:787
isRedirect()
Tests if the article content represents a redirect.
Definition WikiPage.php:634
loadPageData( $from='fromdb')
Load the object from a given source by title.
Definition WikiPage.php:489
isCountable( $editInfo=false)
Determine whether a page would be suitable for being counted as an article in the site_stats table ba...
Definition WikiPage.php:946
const NS_MEDIAWIKI
Definition Defines.php:77
const NS_USER_TALK
Definition Defines.php:72
Base interface for content objects.
Definition Content.php:34
Interface that deferrable updates should implement.
Interface for database access objects.
Interface for objects representing user identity.
An interface for generating database load balancers.
$content
Definition router.php:78