3 * Controller-like object for creating and updating pages by creating new revisions.
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
22 * @author Daniel Kinzler
25 namespace MediaWiki\Storage
;
27 use AtomicSectionUpdate
;
29 use CommentStoreComment
;
36 use MediaWiki\Linker\LinkTarget
;
37 use MediaWiki\Revision\MutableRevisionRecord
;
38 use MediaWiki\Revision\RevisionAccessException
;
39 use MediaWiki\Revision\RevisionRecord
;
40 use MediaWiki\Revision\RevisionStore
;
41 use MediaWiki\Revision\SlotRoleRegistry
;
42 use MediaWiki\Revision\SlotRecord
;
50 use Wikimedia\Assert\Assert
;
51 use Wikimedia\Rdbms\DBConnRef
;
52 use Wikimedia\Rdbms\DBUnexpectedError
;
53 use Wikimedia\Rdbms\IDatabase
;
54 use Wikimedia\Rdbms\ILoadBalancer
;
58 * Controller-like object for creating and updating pages by creating new revisions.
60 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
61 * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
62 * This allows application logic to safely perform edit conflict resolution using the parent
65 * @see docs/pageupdater.txt for more information.
67 * MCR migration note: this replaces the relevant methods in WikiPage.
85 * @var DerivedPageDataUpdater
87 private $derivedDataUpdater;
92 private $loadBalancer;
97 private $revisionStore;
100 * @var SlotRoleRegistry
102 private $slotRoleRegistry;
105 * @var boolean see $wgUseAutomaticEditSummaries
106 * @see $wgUseAutomaticEditSummaries
108 private $useAutomaticEditSummaries = true;
111 * @var int the RC patrol status the new revision should be marked with.
113 private $rcPatrolStatus = RecentChange
::PRC_UNPATROLLED
;
116 * @var bool whether to create a log entry for new page creations.
118 private $usePageCreationLog = true;
121 * @var boolean see $wgAjaxEditStash
123 private $ajaxEditStash = true;
128 private $originalRevId = false;
138 private $undidRevId = 0;
141 * @var RevisionSlotsUpdate
143 private $slotsUpdate;
148 private $status = null;
152 * @param WikiPage $wikiPage
153 * @param DerivedPageDataUpdater $derivedDataUpdater
154 * @param ILoadBalancer $loadBalancer
155 * @param RevisionStore $revisionStore
156 * @param SlotRoleRegistry $slotRoleRegistry
158 public function __construct(
161 DerivedPageDataUpdater
$derivedDataUpdater,
162 ILoadBalancer
$loadBalancer,
163 RevisionStore
$revisionStore,
164 SlotRoleRegistry
$slotRoleRegistry
167 $this->wikiPage
= $wikiPage;
168 $this->derivedDataUpdater
= $derivedDataUpdater;
170 $this->loadBalancer
= $loadBalancer;
171 $this->revisionStore
= $revisionStore;
172 $this->slotRoleRegistry
= $slotRoleRegistry;
174 $this->slotsUpdate
= new RevisionSlotsUpdate();
178 * Can be used to enable or disable automatic summaries that are applied to certain kinds of
179 * changes, like completely blanking a page.
181 * @param bool $useAutomaticEditSummaries
182 * @see $wgUseAutomaticEditSummaries
184 public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
185 $this->useAutomaticEditSummaries
= $useAutomaticEditSummaries;
189 * Sets the "patrolled" status of the edit.
190 * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
192 * @see $wgUseRCPatrol
193 * @see $wgUseNPPatrol
195 * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
197 public function setRcPatrolStatus( $status ) {
198 $this->rcPatrolStatus
= $status;
202 * Whether to create a log entry for new page creations.
204 * @see $wgPageCreationLog
208 public function setUsePageCreationLog( $use ) {
209 $this->usePageCreationLog
= $use;
213 * @param bool $ajaxEditStash
214 * @see $wgAjaxEditStash
216 public function setAjaxEditStash( $ajaxEditStash ) {
217 $this->ajaxEditStash
= $ajaxEditStash;
220 private function getWikiId() {
221 return false; // TODO: get from RevisionStore!
225 * @param int $mode DB_MASTER or DB_REPLICA
229 private function getDBConnectionRef( $mode ) {
230 return $this->loadBalancer
->getConnectionRef( $mode, [], $this->getWikiId() );
236 private function getLinkTarget() {
237 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
238 return $this->wikiPage
->getTitle();
244 private function getTitle() {
245 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
246 return $this->wikiPage
->getTitle();
252 private function getWikiPage() {
253 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
254 return $this->wikiPage
;
258 * Checks whether this update conflicts with another update performed between the client
259 * loading data to prepare an edit, and the client committing the edit. This is intended to
260 * detect user level "edit conflict" when the latest revision known to the client
261 * is no longer the current revision when processing the update.
263 * An update expected to create a new page can be checked by setting $expectedParentRevision = 0.
264 * Such an update is considered to have a conflict if a current revision exists (that is,
265 * the page was created since the edit was initiated on the client).
267 * This method returning true indicates to calling code that edit conflict resolution should
268 * be applied before saving any data. It does not prevent the update from being performed, and
269 * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
270 * A "late" conflict is a CAS failure caused by an update being performed concurrently between
271 * the time grabParentRevision() was called and the time saveRevision() trying to insert the
274 * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
275 * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
276 * This method calls grabParentRevision(), and thus causes the expected parent revision
277 * for the update to be fixed to the page's current revision at this point in time.
278 * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
279 * will fail with the "edit-conflict" status if the current revision of the page changes after
280 * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert
283 * @see grabParentRevision()
285 * @param int $expectedParentRevision The ID of the revision the client expects to be the
286 * current one. Use 0 to indicate that the page is expected to not yet exist.
290 public function hasEditConflict( $expectedParentRevision ) {
291 $parent = $this->grabParentRevision();
292 $parentId = $parent ?
$parent->getId() : 0;
294 return $parentId !== $expectedParentRevision;
298 * Returns the revision that was the page's current revision when grabParentRevision()
299 * was first called. This revision is the expected parent revision of the update, and will be
300 * recorded as the new revision's parent revision (unless no new revision is created because
301 * the content was not changed).
303 * This method MUST not be called after saveRevision() was called!
305 * The current revision determined by the first call to this methods effectively acts a
306 * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
307 * concurrent updates created a new revision.
309 * Application code should call this method before applying transformations to the new
310 * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
311 * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
314 * @see DerivedPageDataUpdater::grabCurrentRevision()
316 * @note The expected parent revision is not to be confused with the logical base revision.
317 * The base revision is specified by the client, the parent revision is determined from the
318 * database. If base revision and parent revision are not the same, the updates is considered
319 * to require edit conflict resolution.
321 * @throws LogicException if called after saveRevision().
322 * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
324 public function grabParentRevision() {
325 return $this->derivedDataUpdater
->grabCurrentRevision();
329 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
332 * @return int Updated $flags
334 private function checkFlags( $flags ) {
335 if ( !( $flags & EDIT_NEW
) && !( $flags & EDIT_UPDATE
) ) {
336 $flags |
= ( $this->derivedDataUpdater
->pageExisted() ) ? EDIT_UPDATE
: EDIT_NEW
;
343 * Set the new content for the given slot role
345 * @param string $role A slot role name (such as "main")
346 * @param Content $content
348 public function setContent( $role, Content
$content ) {
349 $this->ensureRoleAllowed( $role );
351 $this->slotsUpdate
->modifyContent( $role, $content );
355 * Set the new slot for the given slot role
357 * @param SlotRecord $slot
359 public function setSlot( SlotRecord
$slot ) {
360 $this->ensureRoleAllowed( $slot->getRole() );
362 $this->slotsUpdate
->modifySlot( $slot );
366 * Explicitly inherit a slot from some earlier revision.
368 * The primary use case for this is rollbacks, when slots are to be inherited from
369 * the rollback target, overriding the content from the parent revision (which is the
370 * revision being rolled back).
372 * This should typically not be used to inherit slots from the parent revision, which
373 * happens implicitly. Using this method causes the given slot to be treated as "modified"
374 * during revision creation, even if it has the same content as in the parent revision.
376 * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
377 * by the new revision.
379 public function inheritSlot( SlotRecord
$originalSlot ) {
380 // NOTE: slots can be inherited even if the role is not "allowed" on the title.
381 // NOTE: this slot is inherited from some other revision, but it's
382 // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
383 // since it's not implicitly inherited from the parent revision.
384 $inheritedSlot = SlotRecord
::newInherited( $originalSlot );
385 $this->slotsUpdate
->modifySlot( $inheritedSlot );
389 * Removes the slot with the given role.
391 * This discontinues the "stream" of slots with this role on the page,
392 * preventing the new revision, and any subsequent revisions, from
393 * inheriting the slot with this role.
395 * @param string $role A slot role name (but not "main")
397 public function removeSlot( $role ) {
398 $this->ensureRoleNotRequired( $role );
400 $this->slotsUpdate
->removeSlot( $role );
404 * Returns the ID of an earlier revision that is being repeated or restored by this update.
406 * @return bool|int The original revision id, or false if no earlier revision is known to be
407 * repeated or restored by this update.
409 public function getOriginalRevisionId() {
410 return $this->originalRevId
;
414 * Sets the ID of an earlier revision that is being repeated or restored by this update.
415 * The new revision is expected to have the exact same content as the given original revision.
416 * This is used with rollbacks and with dummy "null" revisions which are created to record
417 * things like page moves.
419 * This value is passed to the PageContentSaveComplete and NewRevisionFromEditComplete hooks.
421 * @param int|bool $originalRevId The original revision id, or false if no earlier revision
422 * is known to be repeated or restored by this update.
424 public function setOriginalRevisionId( $originalRevId ) {
425 Assert
::parameterType( 'integer|boolean', $originalRevId, '$originalRevId' );
426 $this->originalRevId
= $originalRevId;
430 * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
431 * undone by this edit.
435 public function getUndidRevisionId() {
436 return $this->undidRevId
;
440 * Sets the ID of revision that was undone by the present update.
441 * This is used with the "undo" action, and is expected to hold the oldest revision ID
442 * in case more then one revision is being undone.
444 * @param int $undidRevId
446 public function setUndidRevisionId( $undidRevId ) {
447 Assert
::parameterType( 'integer', $undidRevId, '$undidRevId' );
448 $this->undidRevId
= $undidRevId;
452 * Sets a tag to apply to this update.
453 * Callers are responsible for permission checks,
454 * using ChangeTags::canAddTagsAccompanyingChange.
457 public function addTag( $tag ) {
458 Assert
::parameterType( 'string', $tag, '$tag' );
459 $this->tags
[] = trim( $tag );
463 * Sets tags to apply to this update.
464 * Callers are responsible for permission checks,
465 * using ChangeTags::canAddTagsAccompanyingChange.
466 * @param string[] $tags
468 public function addTags( array $tags ) {
469 Assert
::parameterElementType( 'string', $tags, '$tags' );
470 foreach ( $tags as $tag ) {
471 $this->addTag( $tag );
476 * Returns the list of tags set using the addTag() method.
480 public function getExplicitTags() {
485 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
488 private function computeEffectiveTags( $flags ) {
491 foreach ( $this->slotsUpdate
->getModifiedRoles() as $role ) {
492 $old_content = $this->getParentContent( $role );
494 $handler = $this->getContentHandler( $role );
495 $content = $this->slotsUpdate
->getModifiedSlot( $role )->getContent();
497 // TODO: MCR: Do this for all slots. Also add tags for removing roles!
498 $tag = $handler->getChangeTag( $old_content, $content, $flags );
499 // If there is no applicable tag, null is returned, so we need to check
505 // Check for undo tag
506 if ( $this->undidRevId
!== 0 && in_array( 'mw-undo', ChangeTags
::getSoftwareTags() ) ) {
510 return array_unique( $tags );
514 * Returns the content of the given slot of the parent revision, with no audience checks applied.
515 * If there is no parent revision or the slot is not defined, this returns null.
517 * @param string $role slot role name
518 * @return Content|null
520 private function getParentContent( $role ) {
521 $parent = $this->grabParentRevision();
523 if ( $parent && $parent->hasSlot( $role ) ) {
524 return $parent->getContent( $role, RevisionRecord
::RAW
);
531 * @param string $role slot role name
532 * @return ContentHandler
534 private function getContentHandler( $role ) {
535 // TODO: inject something like a ContentHandlerRegistry
536 if ( $this->slotsUpdate
->isModifiedSlot( $role ) ) {
537 $slot = $this->slotsUpdate
->getModifiedSlot( $role );
539 $parent = $this->grabParentRevision();
542 $slot = $parent->getSlot( $role, RevisionRecord
::RAW
);
544 throw new RevisionAccessException( 'No such slot: ' . $role );
548 return ContentHandler
::getForModelID( $slot->getModel() );
552 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
554 * @return CommentStoreComment
556 private function makeAutoSummary( $flags ) {
557 if ( !$this->useAutomaticEditSummaries ||
( $flags & EDIT_AUTOSUMMARY
) === 0 ) {
558 return CommentStoreComment
::newUnsavedComment( '' );
561 // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
562 // TODO: combine auto-summaries for multiple slots!
563 // XXX: this logic should not be in the storage layer!
564 $roles = $this->slotsUpdate
->getModifiedRoles();
565 $role = reset( $roles );
567 if ( $role === false ) {
568 return CommentStoreComment
::newUnsavedComment( '' );
571 $handler = $this->getContentHandler( $role );
572 $content = $this->slotsUpdate
->getModifiedSlot( $role )->getContent();
573 $old_content = $this->getParentContent( $role );
574 $summary = $handler->getAutosummary( $old_content, $content, $flags );
576 return CommentStoreComment
::newUnsavedComment( $summary );
580 * Change an existing article or create a new article. Updates RC and all necessary caches,
581 * optionally via the deferred update array. This does not check user permissions.
583 * It is guaranteed that saveRevision() will fail if the current revision of the page
584 * changes after grabParentRevision() was called and before saveRevision() can insert
585 * a new revision, as per the CAS mechanism described above.
587 * The caller is however responsible for calling hasEditConflict() to detect a
588 * user-level edit conflict, and to adjust the content of the new revision accordingly,
589 * e.g. by using a 3-way-merge.
591 * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
592 * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
594 * @param CommentStoreComment $summary Edit summary
595 * @param int $flags Bitfield:
597 * Create a new page, or fail with "edit-already-exists" if the page exists.
599 * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
601 * Mark this revision as minor
603 * Do not log the change in recentchanges
605 * Mark the revision as automated ("bot edit")
607 * Fill in blank summaries with generated text where possible
609 * Signal that the page retrieve/save cycle happened entirely in this request.
611 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
612 * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
613 * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
614 * was unexpectedly created or deleted while revision creation is in progress. This can be
615 * viewed as part of the CAS mechanism described above.
617 * @return RevisionRecord|null The new revision, or null if no new revision was created due
618 * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
619 * to determine the outcome of the revision creation.
621 * @throws MWException
622 * @throws RuntimeException
624 public function saveRevision( CommentStoreComment
$summary, $flags = 0 ) {
625 // Defend against mistakes caused by differences with the
626 // signature of WikiPage::doEditContent.
627 Assert
::parameterType( 'integer', $flags, '$flags' );
629 if ( $this->wasCommitted() ) {
630 throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
633 // Low-level sanity check
634 if ( $this->getLinkTarget()->getText() === '' ) {
635 throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
638 // NOTE: slots can be inherited even if the role is not "allowed" on the title.
639 $status = Status
::newGood();
640 $this->checkAllRolesAllowed(
641 $this->slotsUpdate
->getModifiedRoles(),
644 $this->checkNoRolesRequired(
645 $this->slotsUpdate
->getRemovedRoles(),
649 if ( !$status->isOK() ) {
653 // Make sure the given content is allowed in the respective slots of this page
654 foreach ( $this->slotsUpdate
->getModifiedRoles() as $role ) {
655 $slot = $this->slotsUpdate
->getModifiedSlot( $role );
656 $roleHandler = $this->slotRoleRegistry
->getRoleHandler( $role );
658 if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getTitle() ) ) {
659 $contentHandler = ContentHandler
::getForModelID( $slot->getModel() );
660 $this->status
= Status
::newFatal( 'content-not-allowed-here',
661 ContentHandler
::getLocalizedName( $contentHandler->getModelID() ),
662 $this->getTitle()->getPrefixedText(),
663 wfMessage( $roleHandler->getNameMessageKey() )
664 // TODO: defer message lookup to caller
670 // Load the data from the master database if needed. Needed to check flags.
671 // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
672 // wasn't called yet. If the page is modified by another process before we are done with
673 // it, this method must fail (with status 'edit-conflict')!
674 // NOTE: The parent revision may be different from $this->originalRevisionId.
675 $this->grabParentRevision();
676 $flags = $this->checkFlags( $flags );
678 // Avoid statsd noise and wasted cycles check the edit stash (T136678)
679 if ( ( $flags & EDIT_INTERNAL
) ||
( $flags & EDIT_FORCE_BOT
) ) {
682 $useStashed = $this->ajaxEditStash
;
685 // TODO: use this only for the legacy hook, and only if something uses the legacy hook
686 $wikiPage = $this->getWikiPage();
690 // Prepare the update. This performs PST and generates the canonical ParserOutput.
691 $this->derivedDataUpdater
->prepareContent(
697 // TODO: don't force initialization here!
698 // This is a hack to work around the fact that late initialization of the ParserOutput
699 // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
700 // actual problem, or is just an issue with the test setup, remains to be determined
702 // Anomie said in 2018-03:
704 I suspect that what's breaking is this:
706 The old version of WikiPage::doEditContent() called prepareContentForEdit() which
707 generated the ParserOutput right then, so when doEditUpdates() gets called from the
708 DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
709 there's a comment there that says "Get the pre-save transform content and final
711 The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
712 saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
713 PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
714 Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
715 scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
717 And the order of operations in that Flow test is presumably:
719 - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
720 processing the DeferredUpdate.
721 - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
722 - Then, during the course of doing that test, a $db->commit() results in the
723 DeferredUpdates being run.
725 $this->derivedDataUpdater
->getCanonicalParserOutput();
727 $mainContent = $this->derivedDataUpdater
->getSlots()->getContent( SlotRecord
::MAIN
);
729 // Trigger pre-save hook (using provided edit summary)
730 $hookStatus = Status
::newGood( [] );
731 // TODO: replace legacy hook!
732 // TODO: avoid pass-by-reference, see T193950
733 $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
734 $flags & EDIT_MINOR
, null, null, &$flags, &$hookStatus ];
735 // Check if the hook rejected the attempted save
736 if ( !Hooks
::run( 'PageContentSave', $hook_args ) ) {
737 if ( $hookStatus->isOK() ) {
738 // Hook returned false but didn't call fatal(); use generic message
739 $hookStatus->fatal( 'edit-hook-aborted' );
742 $this->status
= $hookStatus;
746 // Provide autosummaries if one is not provided and autosummaries are enabled
747 // XXX: $summary == null seems logical, but the empty string may actually come from the user
748 // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
749 if ( $summary->text
=== '' && $summary->data
=== null ) {
750 $summary = $this->makeAutoSummary( $flags );
753 // Actually create the revision and create/update the page.
754 // Do NOT yet set $this->status!
755 if ( $flags & EDIT_UPDATE
) {
756 $status = $this->doModify( $summary, $this->user
, $flags );
758 $status = $this->doCreate( $summary, $this->user
, $flags );
761 // Promote user to any groups they meet the criteria for
762 DeferredUpdates
::addCallableUpdate( function () use ( $user ) {
763 $user->addAutopromoteOnceGroups( 'onEdit' );
764 $user->addAutopromoteOnceGroups( 'onView' ); // b/c
767 // NOTE: set $this->status only after all hooks have been called,
768 // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
769 $this->status
= $status;
771 // TODO: replace bad status with Exceptions!
772 return ( $this->status
&& $this->status
->isOK() )
773 ?
$this->status
->value
['revision-record']
778 * Whether saveRevision() has been called on this instance
782 public function wasCommitted() {
783 return $this->status
!== null;
787 * The Status object indicating whether saveRevision() was successful, or null if
788 * saveRevision() was not yet called on this instance.
790 * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
793 * Possible status errors:
794 * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
795 * set the fatal flag of $status.
796 * edit-gone-missing: In update mode, but the article didn't exist.
797 * edit-conflict: In update mode, the article changed unexpectedly.
798 * edit-no-change: Warning that the text was the same as before.
799 * edit-already-exists: In creation mode, but the article already exists.
801 * Extensions may define additional errors.
803 * $return->value will contain an associative array with members as follows:
804 * new: Boolean indicating if the function attempted to create a new article.
805 * revision: The revision object for the inserted revision, or null.
807 * @return null|Status
809 public function getStatus() {
810 return $this->status
;
814 * Whether saveRevision() completed successfully
818 public function wasSuccessful() {
819 return $this->status
&& $this->status
->isOK();
823 * Whether saveRevision() was called and created a new page.
827 public function isNew() {
828 return $this->status
&& $this->status
->isOK() && $this->status
->value
['new'];
832 * Whether saveRevision() did not create a revision because the content didn't change
833 * (null-edit). Whether the content changed or not is determined by
834 * DerivedPageDataUpdater::isChange().
838 public function isUnchanged() {
840 && $this->status
->isOK()
841 && $this->status
->value
['revision-record'] === null;
845 * The new revision created by saveRevision(), or null if saveRevision() has not yet been
846 * called, failed, or did not create a new revision because the content did not change.
848 * @return RevisionRecord|null
850 public function getNewRevision() {
851 return ( $this->status
&& $this->status
->isOK() )
852 ?
$this->status
->value
['revision-record']
857 * Constructs a MutableRevisionRecord based on the Content prepared by the
858 * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
859 * with PST applied, and removing discontinued slots.
861 * This calls Content::prepareSave() to verify that the slot content can be saved.
862 * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
864 * @param CommentStoreComment $comment
867 * @param Status $status
869 * @return MutableRevisionRecord
871 private function makeNewRevision(
872 CommentStoreComment
$comment,
877 $wikiPage = $this->getWikiPage();
878 $title = $this->getTitle();
879 $parent = $this->grabParentRevision();
881 // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
882 // TODO: introduce something like an UnsavedRevisionFactory service instead!
883 /** @var MutableRevisionRecord $rev */
884 $rev = $this->derivedDataUpdater
->getRevision();
885 '@phan-var MutableRevisionRecord $rev';
887 $rev->setPageId( $title->getArticleID() );
890 $oldid = $parent->getId();
891 $rev->setParentId( $oldid );
896 $rev->setComment( $comment );
897 $rev->setUser( $user );
898 $rev->setMinorEdit( ( $flags & EDIT_MINOR
) > 0 );
900 foreach ( $rev->getSlots()->getSlots() as $slot ) {
901 $content = $slot->getContent();
903 // XXX: We may push this up to the "edit controller" level, see T192777.
904 // XXX: prepareSave() and isValid() could live in SlotRoleHandler
905 // XXX: PrepareSave should not take a WikiPage!
906 $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
908 // TODO: MCR: record which problem arose in which slot.
909 $status->merge( $prepStatus );
912 $this->checkAllRequiredRoles(
913 $rev->getSlotRoles(),
921 * @param CommentStoreComment $summary The edit summary
922 * @param User $user The revision's author
923 * @param int $flags EXIT_XXX constants
925 * @throws MWException
928 private function doModify( CommentStoreComment
$summary, User
$user, $flags ) {
929 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
931 // Update article, but only if changed.
932 $status = Status
::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
934 $oldRev = $this->grabParentRevision();
935 $oldid = $oldRev ?
$oldRev->getId() : 0;
938 // Article gone missing
939 $status->fatal( 'edit-gone-missing' );
944 $newRevisionRecord = $this->makeNewRevision(
951 if ( !$status->isOK() ) {
955 $now = $newRevisionRecord->getTimestamp();
957 // XXX: we may want a flag that allows a null revision to be forced!
958 $changed = $this->derivedDataUpdater
->isChange();
960 $dbw = $this->getDBConnectionRef( DB_MASTER
);
963 $dbw->startAtomic( __METHOD__
);
965 // Get the latest page_latest value while locking it.
966 // Do a CAS style check to see if it's the same as when this method
967 // started. If it changed then bail out before touching the DB.
968 $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
969 if ( $latestNow != $oldid ) {
970 // We don't need to roll back, since we did not modify the database yet.
971 // XXX: Or do we want to rollback, any transaction started by calling
972 // code will fail? If we want that, we should probably throw an exception.
973 $dbw->endAtomic( __METHOD__
);
974 // Page updated or deleted in the mean time
975 $status->fatal( 'edit-conflict' );
980 // At this point we are now comitted to returning an OK
981 // status unless some DB query error or other exception comes up.
982 // This way callers don't have to call rollback() if $status is bad
983 // unless they actually try to catch exceptions (which is rare).
985 // Save revision content and meta-data
986 $newRevisionRecord = $this->revisionStore
->insertRevisionOn( $newRevisionRecord, $dbw );
987 $newLegacyRevision = new Revision( $newRevisionRecord );
989 // Update page_latest and friends to reflect the new revision
990 // TODO: move to storage service
991 $wasRedirect = $this->derivedDataUpdater
->wasRedirect();
992 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
993 throw new PageUpdateException( "Failed to update page row to use new revision." );
996 // TODO: replace legacy hook!
997 $tags = $this->computeEffectiveTags( $flags );
999 'NewRevisionFromEditComplete',
1000 [ $wikiPage, $newLegacyRevision, $this->getOriginalRevisionId(), $user, &$tags ]
1003 // Update recentchanges
1004 if ( !( $flags & EDIT_SUPPRESS_RC
) ) {
1005 // Add RC row to the DB
1006 RecentChange
::notifyEdit(
1009 $newRevisionRecord->isMinor(),
1011 $summary->text
, // TODO: pass object when that becomes possible
1013 $newRevisionRecord->getTimestamp(),
1014 ( $flags & EDIT_FORCE_BOT
) > 0,
1017 $newRevisionRecord->getSize(),
1018 $newRevisionRecord->getId(),
1019 $this->rcPatrolStatus
,
1024 $user->incEditCount();
1026 $dbw->endAtomic( __METHOD__
);
1028 // Return the new revision to the caller
1029 $status->value
['revision-record'] = $newRevisionRecord;
1031 // TODO: globally replace usages of 'revision' with getNewRevision()
1032 $status->value
['revision'] = $newLegacyRevision;
1034 // T34948: revision ID must be set to page {{REVISIONID}} and
1035 // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1036 // Since we don't insert a new revision into the database, the least
1037 // error-prone way is to reuse given old revision.
1038 $newRevisionRecord = $oldRev;
1040 $status->warning( 'edit-no-change' );
1041 // Update page_touched as updateRevisionOn() was not called.
1042 // Other cache updates are managed in WikiPage::onArticleEdit()
1043 // via WikiPage::doEditUpdates().
1044 $this->getTitle()->invalidateCache( $now );
1047 // Do secondary updates once the main changes have been committed...
1048 // NOTE: the updates have to be processed before sending the response to the client
1049 // (DeferredUpdates::PRESEND), otherwise the client may already be following the
1050 // HTTP redirect to the standard view before dervide data has been created - most
1051 // importantly, before the parser cache has been updated. This would cause the
1052 // content to be parsed a second time, or may cause stale content to be shown.
1053 DeferredUpdates
::addUpdate(
1054 $this->getAtomicSectionUpdate(
1062 [ 'changed' => $changed, ]
1064 DeferredUpdates
::PRESEND
1071 * @param CommentStoreComment $summary The edit summary
1072 * @param User $user The revision's author
1073 * @param int $flags EXIT_XXX constants
1075 * @throws DBUnexpectedError
1076 * @throws MWException
1079 private function doCreate( CommentStoreComment
$summary, User
$user, $flags ) {
1080 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1082 if ( !$this->derivedDataUpdater
->getSlots()->hasSlot( SlotRecord
::MAIN
) ) {
1083 throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
1086 $status = Status
::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
1088 $newRevisionRecord = $this->makeNewRevision(
1095 if ( !$status->isOK() ) {
1099 $now = $newRevisionRecord->getTimestamp();
1101 $dbw = $this->getDBConnectionRef( DB_MASTER
);
1102 $dbw->startAtomic( __METHOD__
);
1104 // Add the page record unless one already exists for the title
1105 // TODO: move to storage service
1106 $newid = $wikiPage->insertOn( $dbw );
1107 if ( $newid === false ) {
1108 $dbw->endAtomic( __METHOD__
);
1109 $status->fatal( 'edit-already-exists' );
1114 // At this point we are now comitted to returning an OK
1115 // status unless some DB query error or other exception comes up.
1116 // This way callers don't have to call rollback() if $status is bad
1117 // unless they actually try to catch exceptions (which is rare).
1118 $newRevisionRecord->setPageId( $newid );
1120 // Save the revision text...
1121 $newRevisionRecord = $this->revisionStore
->insertRevisionOn( $newRevisionRecord, $dbw );
1122 $newLegacyRevision = new Revision( $newRevisionRecord );
1124 // Update the page record with revision data
1125 // TODO: move to storage service
1126 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
1127 throw new PageUpdateException( "Failed to update page row to use new revision." );
1130 // TODO: replace legacy hook!
1131 $tags = $this->computeEffectiveTags( $flags );
1133 'NewRevisionFromEditComplete',
1134 [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
1137 // Update recentchanges
1138 if ( !( $flags & EDIT_SUPPRESS_RC
) ) {
1139 // Add RC row to the DB
1140 RecentChange
::notifyNew(
1143 $newRevisionRecord->isMinor(),
1145 $summary->text
, // TODO: pass object when that becomes possible
1146 ( $flags & EDIT_FORCE_BOT
) > 0,
1148 $newRevisionRecord->getSize(),
1149 $newRevisionRecord->getId(),
1150 $this->rcPatrolStatus
,
1155 $user->incEditCount();
1157 if ( $this->usePageCreationLog
) {
1158 // Log the page creation
1159 // @TODO: Do we want a 'recreate' action?
1160 $logEntry = new ManualLogEntry( 'create', 'create' );
1161 $logEntry->setPerformer( $user );
1162 $logEntry->setTarget( $this->getTitle() );
1163 $logEntry->setComment( $summary->text
);
1164 $logEntry->setTimestamp( $now );
1165 $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
1166 $logEntry->insert();
1167 // Note that we don't publish page creation events to recentchanges
1168 // (i.e. $logEntry->publish()) since this would create duplicate entries,
1169 // one for the edit and one for the page creation.
1172 $dbw->endAtomic( __METHOD__
);
1174 // Return the new revision to the caller
1175 // TODO: globally replace usages of 'revision' with getNewRevision()
1176 $status->value
['revision'] = $newLegacyRevision;
1177 $status->value
['revision-record'] = $newRevisionRecord;
1179 // Do secondary updates once the main changes have been committed...
1180 DeferredUpdates
::addUpdate(
1181 $this->getAtomicSectionUpdate(
1189 [ 'created' => true ]
1191 DeferredUpdates
::PRESEND
1197 private function getAtomicSectionUpdate(
1200 RevisionRecord
$newRevisionRecord,
1202 CommentStoreComment
$summary,
1207 return new AtomicSectionUpdate(
1211 $wikiPage, $newRevisionRecord, $user,
1212 $summary, $flags, $status, $hints
1215 $hints['causeAction'] = 'edit-page';
1216 $hints['causeAgent'] = $user->getName();
1218 $newLegacyRevision = new Revision( $newRevisionRecord );
1219 $mainContent = $newRevisionRecord->getContent( SlotRecord
::MAIN
, RevisionRecord
::RAW
);
1221 // Update links tables, site stats, etc.
1222 $this->derivedDataUpdater
->prepareUpdate( $newRevisionRecord, $hints );
1223 $this->derivedDataUpdater
->doUpdates();
1225 // TODO: replace legacy hook!
1226 // TODO: avoid pass-by-reference, see T193950
1228 if ( $hints['created'] ??
false ) {
1229 // Trigger post-create hook
1230 $params = [ &$wikiPage, &$user, $mainContent, $summary->text
,
1231 $flags & EDIT_MINOR
, null, null, &$flags, $newLegacyRevision ];
1232 Hooks
::run( 'PageContentInsertComplete', $params );
1235 // Trigger post-save hook
1236 $params = [ &$wikiPage, &$user, $mainContent, $summary->text
,
1237 $flags & EDIT_MINOR
, null, null, &$flags, $newLegacyRevision,
1238 &$status, $this->getOriginalRevisionId(), $this->undidRevId
];
1239 Hooks
::run( 'PageContentSaveComplete', $params );
1245 * @return string[] Slots required for this page update, as a list of role names.
1247 private function getRequiredSlotRoles() {
1248 return $this->slotRoleRegistry
->getRequiredRoles( $this->getTitle() );
1252 * @return string[] Slots allowed for this page update, as a list of role names.
1254 private function getAllowedSlotRoles() {
1255 return $this->slotRoleRegistry
->getAllowedRoles( $this->getTitle() );
1258 private function ensureRoleAllowed( $role ) {
1259 $allowedRoles = $this->getAllowedSlotRoles();
1260 if ( !in_array( $role, $allowedRoles ) ) {
1261 throw new PageUpdateException( "Slot role `$role` is not allowed." );
1265 private function ensureRoleNotRequired( $role ) {
1266 $requiredRoles = $this->getRequiredSlotRoles();
1267 if ( in_array( $role, $requiredRoles ) ) {
1268 throw new PageUpdateException( "Slot role `$role` is required." );
1272 private function checkAllRolesAllowed( array $roles, Status
$status ) {
1273 $allowedRoles = $this->getAllowedSlotRoles();
1275 $forbidden = array_diff( $roles, $allowedRoles );
1276 if ( !empty( $forbidden ) ) {
1278 'edit-slots-cannot-add',
1279 count( $forbidden ),
1280 implode( ', ', $forbidden )
1285 private function checkNoRolesRequired( array $roles, Status
$status ) {
1286 $requiredRoles = $this->getRequiredSlotRoles();
1288 $needed = array_diff( $roles, $requiredRoles );
1289 if ( !empty( $needed ) ) {
1291 'edit-slots-cannot-remove',
1293 implode( ', ', $needed )
1298 private function checkAllRequiredRoles( array $roles, Status
$status ) {
1299 $requiredRoles = $this->getRequiredSlotRoles();
1301 $missing = array_diff( $requiredRoles, $roles );
1302 if ( !empty( $missing ) ) {
1304 'edit-slots-missing',
1306 implode( ', ', $missing )