[MCR] Factor PageUpdater out of WikiPage
authordaniel <daniel.kinzler@wikimedia.de>
Sat, 27 Jan 2018 01:48:19 +0000 (17:48 -0800)
committerAddshore <addshorewiki@gmail.com>
Thu, 14 Jun 2018 13:22:13 +0000 (13:22 +0000)
This introduces PageUpdater to replace WikiPage::doEditContent,
and DerivedPageDataUpdater, to replace WikiPage::doEditUpdates
and WikiPage::prepareContentForEdit.

See docs/pageupdater.txt for a description of their
functionality.

MCR migration notes:

* The interface of PageUpdater is expected to
remain mostly stable after this patch. Code that has been using
WikiPage::doEditContent can be confidently migrated to using the
new mechanism for revision creation.

* This patch keeps the code inside PageUpdater largely aligned
with the old code in WikiPage, to make review easier to to avoid
mistakes. It is intended to be refactored further, moving
application logic into stateless services.

* DerivedPageDataUpdate is intended as a stepping stone for further
refactoring. Its behavior is designed to be compatible with
callback code that currently relies on
WikiPage::prepareContentForEdit. Much of the code that currently
lives in DerivedPageDataUpdate should be factored out into
services, all behavior relevant to calling code should be exposed
via narrow interfaces.

Bug: T174038
Bug: T196653
Change-Id: If610c68f4912e89af616cdcac1d35a1be3946afa

25 files changed:
docs/pageupdater.txt [new file with mode: 0644]
includes/EditPage.php
includes/Storage/DerivedPageDataUpdater.php [new file with mode: 0644]
includes/Storage/MutableRevisionRecord.php
includes/Storage/MutableRevisionSlots.php
includes/Storage/PageUpdateException.php [new file with mode: 0644]
includes/Storage/PageUpdater.php [new file with mode: 0644]
includes/Storage/RevisionRecord.php
includes/Storage/RevisionSlots.php
includes/Storage/RevisionSlotsUpdate.php
includes/changes/RecentChange.php
includes/edit/PreparedEdit.php
includes/page/WikiPage.php
includes/resourceloader/ResourceLoaderWikiModule.php
includes/user/User.php
tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php
tests/phpunit/includes/Storage/PageUpdaterTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionSlotsTest.php
tests/phpunit/includes/Storage/RevisionSlotsUpdateTest.php
tests/phpunit/includes/Storage/RevisionStoreRecordTest.php
tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php
tests/phpunit/includes/page/WikiPageDbTestBase.php
tests/phpunit/includes/user/UserTest.php

diff --git a/docs/pageupdater.txt b/docs/pageupdater.txt
new file mode 100644 (file)
index 0000000..4980c92
--- /dev/null
@@ -0,0 +1,191 @@
+This document provides an overview of the usage of PageUpdater and DerivedPageDataUpdater.
+
+== PageUpdater ==
+PageUpdater is the canonical way to create page revisions, that is, to perform edits.
+
+PageUpdater is a stateful, handle-like object that allows new revisions to be created
+on a given wiki page using the saveRevision() method. PageUpdater provides setters for
+defining the new revision's content as well as meta-data such as change tags. saveRevision()
+stores the new revision's primary content and metadata, and triggers the necessary
+updates to derived secondary data and cached artifacts e.g. in the ParserCache and the
+CDN layer, using a DerivedPageDataUpdater.
+
+PageUpdater instances follow the below life cycle, defined by a number of
+methods:
+
+                          +----------------------------+
+                          |                            |
+                          |             new            |
+                          |                            |
+                          +------|--------------|------+
+                                 |              |
+            grabParentRevision()-|              |
+            or hasEditConflict()-|              |
+                                 |              |
+                        +--------v-------+      |
+                        |                |      |
+                        |  parent known  |      |
+                        |                |      |
+  Enables---------------+--------|-------+      |
+    safe operations based on     |              |-saveRevision()
+    the parent revision, e.g.    |              |
+    section replacement or       |              |
+    edit conflict resolution.    |              |
+                                 |              |
+                  saveRevision()-|              |
+                                 |              |
+                          +------v--------------v------+
+                          |                            |
+                          |      creation committed    |
+                          |                            |
+  Enables-----------------+----------------------------+
+    wasSuccess()
+    isUnchanged()
+    isNew()
+    getState()
+    getNewRevision()
+    etc.
+
+The stateful nature of PageUpdater allows it to be used to safely perform
+transformations that depend on the new revision's parent revision, such as replacing
+sections or applying 3-way conflict resolution, while protecting against race
+conditions using a compare-and-swap (CAS) mechanism: after calling code used the
+grabParentRevision() method to access the edit's logical parent, PageUpdater
+remembers that revision, and ensure that that revision is still the page's current
+revision when performing the atomic database update for the revision's primary
+meta-data when saveRevision() is called. If another revision was created concurrently,
+saveRevision() will fail, indicating the problem with the "edit-conflict" code in the status
+object.
+
+Typical usage for programmatic revision creation (with $page being a WikiPage as of 1.32, to be
+replaced by a repository service later):
+
+  $updater = $page->newPageUpdater( $user );
+  $updater->setContent( 'main', $content );
+  $updater->setRcPatrolStatus( RecentChange::PRC_PATROLLED );
+  $newRev = $updater->saveRevision( $comment );
+
+Usage with content depending on the parent revision
+
+  $updater = $page->newPageUpdater( $user );
+  $parent = $updater->grabParentRevision();
+  $content = $parent->getContent( 'main' )->replaceSection( $section, $sectionContent );
+  $updater->setContent( 'main', $content );
+  $newRev = $updater->saveRevision( $comment, EDIT_UPDATE );
+
+In both cases, all secondary updates will be triggered automatically.
+
+== DerivedPageDataUpdater ==
+DerivedPageDataUpdater is a stateful, handle-like object that caches derived data representing
+a revision, and can trigger updates of cached copies of that data, e.g. in the links tables,
+page_props, the ParserCache, and the CDN layer.
+
+DerivedPageDataUpdater is used by PageUpdater when creating new revisions, but can also
+be used independently when performing meta data updates during undeletion, import, or
+when puring a page. It's a stepping stone on the way to a more complete refactoring of WikiPage.
+
+NOTE: Avoid direct usage of DerivedPageDataUpdater. In the future, we want to define interfaces
+for the different use cases of DerivedPageDataUpdater, particularly providing access to post-PST
+content and ParserOutput to callbacks during revision creation, which currently use
+WikiPage::prepareContentForEdit, and allowing updates to be triggered on purge, import, and
+undeletion, which currently use WikiPage::doEditUpdates() and Content::getSecondaryDataUpdates().
+
+The primary reason for DerivedPageDataUpdater to be stateful is internal caching of state
+that avoids the re-generation of ParserOutput and re-application of pre-save-
+transformations (PST).
+
+DerivedPageDataUpdater instances follow the below life cycle, defined by a number of
+methods:
+
+                       +---------------------------------------------------------------------+
+                       |                                                                     |
+                       |                                 new                                 |
+                       |                                                                     |
+                       +---------------|------------------|------------------|---------------+
+                                       |                  |                  |
+                 grabCurrentRevision()-|                  |                  |
+                                       |                  |                  |
+                           +-----------v----------+       |                  |
+                           |                      |       |-prepareContent() |
+                           |    knows current     |       |                  |
+                           |                      |       |                  |
+  Enables------------------+-----|-----|----------+       |                  |
+    pageExisted()                |     |                  |                  |
+    wasRedirect()                |     |-prepareContent() |                  |-prepareUpdate()
+                                 |     |                  |                  |
+                                 |     |    +-------------v------------+     |
+                                 |     |    |                          |     |
+                                 |     +---->        has content       |     |
+                                 |          |                          |     |
+  Enables------------------------|----------+--------------------------+     |
+    isChange()                   |                              |            |
+    isCreation()                 |-prepareUpdate()              |            |
+    getSlots()                   |              prepareUpdate()-|            |
+    getTouchedSlotRoles()        |                              |            |
+    getCanonicalParserOutput()   |                  +-----------v------------v-----------------+
+                                 |                  |                                          |
+                                 +------------------>                 has revision             |
+                                                    |                                          |
+  Enables-------------------------------------------+------------------------|-----------------+
+    updateParserCache()                                                      |
+    runSecondaryDataUpdates()                                                |-doUpdates()
+                                                                             |
+                                                                 +-----------v---------+
+                                                                 |                     |
+                                                                 |     updates done    |
+                                                                 |                     |
+                                                                 +---------------------+
+
+
+- grabCurrentRevision() returns the logical parent revision of the target revision. It is
+guaranteed to always return the same revision for a given DerivedPageDataUpdater instance.
+If called before prepareUpdate(), this fixates the logical parent to be the page's current
+revision. If called for the first time after prepareUpdate(), it returns the revision
+passed as the 'oldrevision' option to prepareUpdate(), or, if that wasn't given, the
+parent of $revision parameter passed to prepareUpdate().
+
+- prepareContent() is called before the new revision is created, to apply pre-save-
+transformation (PST) and allow subsequent access to the canonical ParserOutput of the
+revision. getSlots() and getCanonicalParserOutput() as well as getSecondaryDataUpdates()
+may be used after prepareContent() was called. Calling prepareContent() with the same
+parameters again has no effect. Calling it again with mismatching paramters, or calling
+it after prepareUpdate() was called, triggers a LogicException.
+
+- prepareUpdate() is called after the new revision has been created. This may happen
+right after the revision was created, on the same instance on which prepareContent() was
+called, or later (possibly much later), on a fresh instance in a different process,
+due to deferred or asynchronous updates, or during import, undeletion, purging, etc.
+prepareUpdate() is required before a call to doUpdates(), and it also enables calls to
+getSlots() and getCanonicalParserOutput() as well as getSecondaryDataUpdates().
+Calling prepareUpdate() with the same parameters again has no effect.
+Calling it again with mismatching parameters, or calling it with parameters mismatching
+the ones prepareContent() was called with, triggers a LogicException.
+
+- getSecondaryDataUpdtes() returns DataUpdates that represent derived data for the revision.
+These may be used to update such data, e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
+script.
+
+- doUpdates() triggers the updates defined by getSecondaryDataUpdtes(), and also causes
+updates to cached artifacts in the ParserCache, the CDN layer, etc. This is primarily
+used by PageUpdater, but also by PageArchive during undeletion, and when importing
+revisions from XML. doUpdates() can only be called after prepareUpdate() was used to
+initialize the DerivedPageDataUpdater instance for a specific revision. Calling it before
+prepareUpdate() is called raises a LogicException.
+
+A DerivedPageDataUpdater instance is intended to be re-used during different stages
+of complex update operations that often involve callbacks to extension code via
+MediaWiki's hook mechanism, or deferred or even asynchronous execution of Jobs and
+DeferredUpdates. Since these mechanisms typically do not provide a way to pass a
+DerivedPageDataUpdater directly, WikiPage::getDerivedPageDataUpdater() has to be used to
+obtain a DerivedPageDataUpdater for the update currently in progress - re-using the
+same DerivedPageDataUpdater if possible avoids re-generation of ParserOutput objects
+and other expensively derived artifacts.
+
+This mechanism for re-using a DerivedPageDataUpdater instance without passing it directly
+requires a way to ensure that a given DerivedPageDataUpdater instance can actually be used
+in the calling code's context. For this purpose, WikiPage::getDerivedPageDataUpdater()
+calls the isReusableFor() method on DerivedPageDataUpdater, which ensures that the given
+instance is applicable to the given parameters. In other words, isReusableFor() predicts
+whether calling prepareContent() or prepareUpdate() with a given set of parameters will
+trigger a LogicException. In that case, WikiPage::getDerivedPageDataUpdater() creates a
+fresh DerivedPageDataUpdater instance.
index 644b625..9aa6550 100644 (file)
@@ -1495,7 +1495,11 @@ class EditPage {
         * @return Status The resulting status object.
         */
        public function attemptSave( &$resultDetails = false ) {
-               # Allow bots to exempt some edits from bot flagging
+               // TODO: MCR: treat $this->minoredit like $this->bot and check isAllowed( 'minoredit' )!
+               // Also, add $this->autopatrol like $this->bot and check isAllowed( 'autopatrol' )!
+               // This is needed since PageUpdater no longer checks these rights!
+
+               // Allow bots to exempt some edits from bot flagging
                $bot = $this->context->getUser()->isAllowed( 'bot' ) && $this->bot;
                $status = $this->internalAttemptSave( $resultDetails, $bot );
 
diff --git a/includes/Storage/DerivedPageDataUpdater.php b/includes/Storage/DerivedPageDataUpdater.php
new file mode 100644 (file)
index 0000000..cc72754
--- /dev/null
@@ -0,0 +1,1542 @@
+<?php
+/**
+ * A handle for managing updates for derived page data on edit, import, purge, etc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use ApiStashEdit;
+use CategoryMembershipChangeJob;
+use Content;
+use ContentHandler;
+use DataUpdate;
+use DeferredUpdates;
+use Hooks;
+use IDBAccessObject;
+use InvalidArgumentException;
+use JobQueueGroup;
+use Language;
+use LinksUpdate;
+use LogicException;
+use MediaWiki\Edit\PreparedEdit;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentity;
+use MessageCache;
+use ParserCache;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use RecentChangesUpdateJob;
+use ResourceLoaderWikiModule;
+use Revision;
+use SearchUpdate;
+use SiteStatsUpdate;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+use WikiPage;
+
+/**
+ * A handle for managing updates for derived page data on edit, import, purge, etc.
+ *
+ * @note Avoid direct usage of DerivedPageDataUpdater.
+ *
+ * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
+ * providing access to post-PST content and ParserOutput to callbacks during revision creation,
+ * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
+ * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
+ * Content::getSecondaryDataUpdates().
+ *
+ * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
+ * and re-used by callback code over the course of an update operation. It's a stepping stone
+ * one the way to a more complete refactoring of WikiPage.
+ *
+ * When using a DerivedPageDataUpdater, the following life cycle must be observed:
+ * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
+ * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
+ * require prepareContent or prepareUpdate to have been called first, to initialize the
+ * DerivedPageDataUpdater.
+ *
+ * @see docs/pageupdater.txt for more information.
+ *
+ * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
+ * of PreparedEdit.
+ *
+ * @internal
+ *
+ * @since 1.32
+ * @ingroup Page
+ */
+class DerivedPageDataUpdater implements IDBAccessObject {
+
+       /**
+        * @var UserIdentity|null
+        */
+       private $user = null;
+
+       /**
+        * @var WikiPage
+        */
+       private $wikiPage;
+
+       /**
+        * @var ParserCache
+        */
+       private $parserCache;
+
+       /**
+        * @var RevisionStore
+        */
+       private $revisionStore;
+
+       /**
+        * @var Language
+        */
+       private $contentLanguage;
+
+       /**
+        * @var LoggerInterface
+        */
+       private $saveParseLogger;
+
+       /**
+        * @var JobQueueGroup
+        */
+       private $jobQueueGroup;
+
+       /**
+        * @var MessageCache
+        */
+       private $messageCache;
+
+       /**
+        * @var string see $wgArticleCountMethod
+        */
+       private $articleCountMethod;
+
+       /**
+        * @var boolean see $wgRCWatchCategoryMembership
+        */
+       private $rcWatchCategoryMembership = false;
+
+       /**
+        * See $options on prepareUpdate.
+        */
+       private $options = [
+               'changed' => true,
+               'created' => false,
+               'moved' => false,
+               'restored' => false,
+               'oldcountable' => null,
+               'oldredirect' => null,
+       ];
+
+       /**
+        * The state of the relevant row in page table before the edit.
+        * This is determined by the first call to grabCurrentRevision, prepareContent,
+        * or prepareUpdate.
+        * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
+        * attempt to emulate the state of the page table before the edit.
+        *
+        * @var array
+        */
+       private $pageState = null;
+
+       /**
+        * @var RevisionSlotsUpdate|null
+        */
+       private $slotsUpdate = null;
+
+       /**
+        * @var MutableRevisionSlots|null
+        */
+       private $pstContentSlots = null;
+
+       /**
+        * @var object[] anonymous objects with two fields, using slot roles as keys:
+        *  - hasHtml: whether the output contains HTML
+        *  - ParserOutput: the slot's parser output
+        */
+       private $slotsOutput = [];
+
+       /**
+        * @var ParserOutput|null
+        */
+       private $canonicalParserOutput = null;
+
+       /**
+        * @var ParserOptions|null
+        */
+       private $canonicalParserOptions = null;
+
+       /**
+        * @var RevisionRecord
+        */
+       private $revision = null;
+
+       /**
+        * A stage identifier for managing the life cycle of this instance.
+        * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
+        *
+        * @see docs/pageupdater.txt for documentation of the life cycle.
+        *
+        * @var string
+        */
+       private $stage = 'new';
+
+       /**
+        * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
+        *
+        * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
+        * and constants are also overkill...
+        *
+        * @see docs/pageupdater.txt for documentation of the life cycle.
+        *
+        * @var array[]
+        */
+       private static $transitions = [
+               'new' => [
+                       'new' => true,
+                       'knows-current' => true,
+                       'has-content' => true,
+                       'has-revision' => true,
+               ],
+               'knows-current' => [
+                       'knows-current' => true,
+                       'has-content' => true,
+                       'has-revision' => true,
+               ],
+               'has-content' => [
+                       'has-content' => true,
+                       'has-revision' => true,
+               ],
+               'has-revision' => [
+                       'has-revision' => true,
+                       'done' => true,
+               ],
+       ];
+
+       /**
+        * @param WikiPage $wikiPage ,
+        * @param RevisionStore $revisionStore
+        * @param ParserCache $parserCache
+        * @param JobQueueGroup $jobQueueGroup
+        * @param MessageCache $messageCache
+        * @param Language $contentLanguage
+        * @param LoggerInterface $saveParseLogger
+        */
+       public function __construct(
+               WikiPage $wikiPage,
+               RevisionStore $revisionStore,
+               ParserCache $parserCache,
+               JobQueueGroup $jobQueueGroup,
+               MessageCache $messageCache,
+               Language $contentLanguage,
+               LoggerInterface $saveParseLogger = null
+       ) {
+               $this->wikiPage = $wikiPage;
+
+               $this->parserCache = $parserCache;
+               $this->revisionStore = $revisionStore;
+               $this->jobQueueGroup = $jobQueueGroup;
+               $this->messageCache = $messageCache;
+               $this->contentLanguage = $contentLanguage;
+
+               // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here?
+               $this->saveParseLogger = $saveParseLogger ?: new NullLogger();
+       }
+
+       /**
+        * Transition function for managing the life cycle of this instances.
+        *
+        * @see docs/pageupdater.txt for documentation of the life cycle.
+        *
+        * @param string $newStage the new stage
+        * @return string the previous stage
+        *
+        * @throws LogicException If a transition to the given stage is not possible in the current
+        *         stage.
+        */
+       private function doTransition( $newStage ) {
+               $this->assertTransition( $newStage );
+
+               $oldStage = $this->stage;
+               $this->stage = $newStage;
+
+               return $oldStage;
+       }
+
+       /**
+        * Asserts that a transition to the given stage is possible, without performing it.
+        *
+        * @see docs/pageupdater.txt for documentation of the life cycle.
+        *
+        * @param string $newStage the new stage
+        *
+        * @throws LogicException If this instance is not in the expected stage
+        */
+       private function assertTransition( $newStage ) {
+               if ( empty( self::$transitions[$this->stage][$newStage] ) ) {
+                       throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
+               }
+       }
+
+       /**
+        * @return bool|string
+        */
+       private function getWikiId() {
+               // TODO: get from RevisionStore
+               return false;
+       }
+
+       /**
+        * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
+        * the the given revision.
+        *
+        * @param UserIdentity|null $user The user creating the revision in question
+        * @param RevisionRecord|null $revision New revision (after save, if already saved)
+        * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
+        * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
+        *
+        * @return bool
+        */
+       public function isReusableFor(
+               UserIdentity $user = null,
+               RevisionRecord $revision = null,
+               RevisionSlotsUpdate $slotsUpdate = null,
+               $parentId = null
+       ) {
+               if ( $revision
+                       && $parentId
+                       && $revision->getParentId() !== $parentId
+               ) {
+                       throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
+               }
+
+               if ( $revision
+                       && $user
+                       && $revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
+               ) {
+                       throw new InvalidArgumentException( '$user should match the author of $revision' );
+               }
+
+               if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
+                       return false;
+               }
+
+               if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) {
+                       return false;
+               }
+
+               if ( $revision && !$user ) {
+                       $user = $revision->getUser( RevisionRecord::RAW );
+               }
+
+               if ( $this->pageState
+                       && $revision
+                       && $revision->getParentId() !== null
+                       && $this->pageState['oldId'] !== $revision->getParentId()
+               ) {
+                       return false;
+               }
+
+               if ( $this->pageState
+                       && $parentId !== null
+                       && $this->pageState['oldId'] !== $parentId
+               ) {
+                       return false;
+               }
+
+               if ( $this->revision
+                       && $user
+                       && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
+               ) {
+                       return false;
+               }
+
+               if ( $revision
+                       && $this->user
+                       && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
+               ) {
+                       return false;
+               }
+
+               // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
+               if ( $this->slotsUpdate
+                       && $slotsUpdate
+                       && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
+               ) {
+                       return false;
+               }
+
+               if ( $this->pstContentSlots
+                       && $revision
+                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               ) {
+                       return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * @param string $articleCountMethod "any" or "link".
+        * @see $wgArticleCountMethod
+        */
+       public function setArticleCountMethod( $articleCountMethod ) {
+               $this->articleCountMethod = $articleCountMethod;
+       }
+
+       /**
+        * @param bool $rcWatchCategoryMembership
+        * @see $wgRCWatchCategoryMembership
+        */
+       public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
+               $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
+       }
+
+       /**
+        * @return Title
+        */
+       private function getTitle() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage->getTitle();
+       }
+
+       /**
+        * @return WikiPage
+        */
+       private function getWikiPage() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage;
+       }
+
+       /**
+        * Determines whether the page being edited already existed.
+        * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
+        *
+        * @return bool
+        * @throws LogicException if called before grabCurrentRevision
+        */
+       public function pageExisted() {
+               $this->assertHasPageState( __METHOD__ );
+
+               return $this->pageState['oldId'] > 0;
+       }
+
+       /**
+        * Returns the revision that was current before the edit. This would be null if the edit
+        * created the page, or the revision's parent for a regular edit, or the revision itself
+        * for a null-edit.
+        * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
+        *
+        * @return RevisionRecord|null the revision that was current before the edit, or null if
+        *         the edit created the page.
+        */
+       private function getOldRevision() {
+               $this->assertHasPageState( __METHOD__ );
+
+               // If 'oldRevision' is not set, load it!
+               // Useful if $this->oldPageState is initialized by prepareUpdate.
+               if ( !array_key_exists( 'oldRevision', $this->pageState ) ) {
+                       /** @var int $oldId */
+                       $oldId = $this->pageState['oldId'];
+                       $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
+                       $this->pageState['oldRevision'] = $oldId
+                               ? $this->revisionStore->getRevisionById( $oldId, $flags )
+                               : null;
+               }
+
+               return $this->pageState['oldRevision'];
+       }
+
+       /**
+        * Returns the revision that was the page's current revision when grabCurrentRevision()
+        * was first called.
+        *
+        * During an edit, that revision will act as the logical parent of the new revision.
+        *
+        * Some updates are performed based on the difference between the database state at the
+        * moment this method is first called, and the state after the edit.
+        *
+        * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+        *
+        * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
+        * to avoid confusion, since the page's current revision is then the new revision after
+        * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
+        * Use getOldRevision() instead to access the revision that used to be current before the
+        * edit.
+        *
+        * @return RevisionRecord|null the page's current revision, or null if the page does not
+        * yet exist.
+        */
+       public function grabCurrentRevision() {
+               if ( $this->pageState ) {
+                       return $this->pageState['oldRevision'];
+               }
+
+               $this->assertTransition( 'knows-current' );
+
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               $wikiPage = $this->getWikiPage();
+
+               // Do not call WikiPage::clear(), since the caller may already have caused page data
+               // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
+               $wikiPage->loadPageData( self::READ_LATEST );
+               $rev = $wikiPage->getRevision();
+               $current = $rev ? $rev->getRevisionRecord() : null;
+
+               $this->pageState = [
+                       'oldRevision' => $current,
+                       'oldId' => $rev ? $rev->getId() : 0,
+                       'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
+                       'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
+               ];
+
+               $this->doTransition( 'knows-current' );
+
+               return $this->pageState['oldRevision'];
+       }
+
+       /**
+        * Whether prepareUpdate() or prepareContent() have been called on this instance.
+        *
+        * @return bool
+        */
+       public function isContentPrepared() {
+               return $this->pstContentSlots !== null;
+       }
+
+       /**
+        * Whether prepareUpdate() has been called on this instance.
+        *
+        * @return bool
+        */
+       public function isUpdatePrepared() {
+               return $this->revision !== null;
+       }
+
+       /**
+        * @return int
+        */
+       private function getPageId() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage->getId();
+       }
+
+       /**
+        * @return string
+        */
+       private function getTimestampNow() {
+               // TODO: allow an override to be injected for testing
+               return wfTimestampNow();
+       }
+
+       /**
+        * Whether the content of the target revision is publicly visible.
+        *
+        * @return bool
+        */
+       public function isContentPublic() {
+               if ( $this->revision ) {
+                       // XXX: if that revision is the current revision, this can be skipped
+                       return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+               } else {
+                       // If the content has not been saved yet, it cannot have been suppressed yet.
+                       return true;
+               }
+       }
+
+       /**
+        * Returns the slot, modified or inherited, after PST, with no audience checks applied.
+        *
+        * @param string $role slot role name
+        *
+        * @throws PageUpdateException If the slot is neither set for update nor inherited from the
+        *        parent revision.
+        * @return SlotRecord
+        */
+       public function getRawSlot( $role ) {
+               return $this->getSlots()->getSlot( $role );
+       }
+
+       /**
+        * Returns the content of the given slot, with no audience checks.
+        *
+        * @throws PageUpdateException If the slot is neither set for update nor inherited from the
+        *        parent revision.
+        * @param string $role slot role name
+        * @return Content
+        */
+       public function getRawContent( $role ) {
+               return $this->getRawSlot( $role )->getContent();
+       }
+
+       /**
+        * Returns the content model of the given slot
+        *
+        * @param string $role slot role name
+        * @return string
+        */
+       private function getContentModel( $role ) {
+               return $this->getRawSlot( $role )->getModel();
+       }
+
+       /**
+        * @param string $role slot role name
+        * @return ContentHandler
+        */
+       private function getContentHandler( $role ) {
+               // TODO: inject something like a ContentHandlerRegistry
+               return ContentHandler::getForModelID( $this->getContentModel( $role ) );
+       }
+
+       private function useMaster() {
+               // TODO: can we just set a flag to true in prepareContent()?
+               return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
+       }
+
+       /**
+        * @return bool
+        */
+       public function isCountable() {
+               // NOTE: Keep in sync with WikiPage::isCountable.
+
+               if ( !$this->getTitle()->isContentPage() ) {
+                       return false;
+               }
+
+               if ( !$this->isContentPublic() ) {
+                       // This should be irrelevant: countability only applies to the current revision,
+                       // and the current revision is never suppressed.
+                       return false;
+               }
+
+               if ( $this->isRedirect() ) {
+                       return false;
+               }
+
+               $hasLinks = null;
+
+               if ( $this->articleCountMethod === 'link' ) {
+                       $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
+               }
+
+               // TODO: MCR: ask all slots if they have links [SlotHandler/PageTypeHandler]
+               $mainContent = $this->getRawContent( 'main' );
+               return $mainContent->isCountable( $hasLinks );
+       }
+
+       /**
+        * @return bool
+        */
+       public function isRedirect() {
+               // NOTE: main slot determines redirect status
+               $mainContent = $this->getRawContent( 'main' );
+
+               return $mainContent->isRedirect();
+       }
+
+       /**
+        * @param RevisionRecord $rev
+        *
+        * @return bool
+        */
+       private function revisionIsRedirect( RevisionRecord $rev ) {
+               // NOTE: main slot determines redirect status
+               $mainContent = $rev->getContent( 'main', RevisionRecord::RAW );
+
+               return $mainContent->isRedirect();
+       }
+
+       /**
+        * Prepare updates based on an update which has not yet been saved.
+        *
+        * This may be used to create derived data that is needed when creating a new revision;
+        * particularly, this makes available the slots of the new revision via the getSlots()
+        * method, after applying PST and slot inheritance.
+        *
+        * The derived data prepared for revision creation may then later be re-used by doUpdates(),
+        * without the need to re-calculate.
+        *
+        * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+        *
+        * @note: Calling this method more than once with the same $slotsUpdate
+        * has no effect. Calling this method multiple times with different content will cause
+        * an exception.
+        *
+        * @note: Calling this method after prepareUpdate() has been called will cause an exception.
+        *
+        * @param User $user The user to act as context for pre-save transformation (PST).
+        *        Type hint should be reduced to UserIdentity at some point.
+        * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
+        *        by this edit, before PST.
+        * @param bool $useStash Whether to use stashed ParserOutput
+        */
+       public function prepareContent(
+               User $user,
+               RevisionSlotsUpdate $slotsUpdate,
+               $useStash = true
+       ) {
+               if ( $this->slotsUpdate ) {
+                       if ( !$this->user ) {
+                               throw new LogicException(
+                                       'Unexpected state: $this->slotsUpdate was initialized, '
+                                       . 'but $this->user was not.'
+                               );
+                       }
+
+                       if ( $this->user->getName() !== $user->getName() ) {
+                               throw new LogicException( 'Can\'t call prepareContent() again for different user! '
+                                       . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
+                               );
+                       }
+
+                       if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
+                               throw new LogicException(
+                                       'Can\'t call prepareContent() again with different slot content!'
+                               );
+                       }
+
+                       return; // prepareContent() already done, nothing to do
+               }
+
+               $this->assertTransition( 'has-content' );
+
+               $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
+               $title = $this->getTitle();
+
+               $parentRevision = $this->grabCurrentRevision();
+
+               $this->slotsOutput = [];
+               $this->canonicalParserOutput = null;
+               $this->canonicalParserOptions = null;
+
+               // The edit may have already been prepared via api.php?action=stashedit
+               $stashedEdit = false;
+
+               // TODO: MCR: allow output for all slots to be stashed.
+               if ( $useStash && $slotsUpdate->isModifiedSlot( 'main' ) ) {
+                       $mainContent = $slotsUpdate->getModifiedSlot( 'main' )->getContent();
+                       $legacyUser = User::newFromIdentity( $user );
+                       $stashedEdit = ApiStashEdit::checkCache( $title, $mainContent, $legacyUser );
+               }
+
+               if ( $stashedEdit ) {
+                       /** @var ParserOutput $output */
+                       $output = $stashedEdit->output;
+
+                       // TODO: this should happen when stashing the ParserOutput, not now!
+                       $output->setCacheTime( $stashedEdit->timestamp );
+
+                       // TODO: MCR: allow output for all slots to be stashed.
+                       $this->canonicalParserOutput = $output;
+               }
+
+               $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contentLanguage );
+               Hooks::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
+
+               $this->user = $user;
+               $this->slotsUpdate = $slotsUpdate;
+
+               if ( $parentRevision ) {
+                       // start out by inheriting all parent slots
+                       $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots(
+                               $parentRevision->getSlots()->getSlots()
+                       );
+               } else {
+                       $this->pstContentSlots = new MutableRevisionSlots();
+               }
+
+               foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
+                       $slot = $slotsUpdate->getModifiedSlot( $role );
+
+                       if ( $slot->isInherited() ) {
+                               // No PST for inherited slots! Note that "modified" slots may still be inherited
+                               // from an earlier version, e.g. for rollbacks.
+                               $pstSlot = $slot;
+                       } elseif ( $role === 'main' && $stashedEdit ) {
+                               // TODO: MCR: allow PST content for all slots to be stashed.
+                               $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
+                       } else {
+                               $content = $slot->getContent();
+                               $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
+                               $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
+                       }
+
+                       $this->pstContentSlots->setSlot( $pstSlot );
+               }
+
+               foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
+                       $this->pstContentSlots->removeSlot( $role );
+               }
+
+               $this->options['created'] = ( $parentRevision === null );
+               $this->options['changed'] = ( $parentRevision === null
+                       || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
+
+               $this->doTransition( 'has-content' );
+       }
+
+       private function assertHasPageState( $method ) {
+               if ( !$this->pageState ) {
+                       throw new LogicException(
+                               'Must call grabCurrentRevision() or prepareContent() '
+                               . 'or prepareUpdate() before calling ' . $method
+                       );
+               }
+       }
+
+       private function assertPrepared( $method ) {
+               if ( !$this->pstContentSlots ) {
+                       throw new LogicException(
+                               'Must call prepareContent() or prepareUpdate() before calling ' . $method
+                       );
+               }
+       }
+
+       /**
+        * Whether the edit creates the page.
+        *
+        * @return bool
+        */
+       public function isCreation() {
+               $this->assertPrepared( __METHOD__ );
+               return $this->options['created'];
+       }
+
+       /**
+        * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
+        *
+        * @warning: at present, "null-revisions" that do not change content but do have a revision
+        * record would return false after prepareContent(), but true after prepareUpdate()!
+        * This should probably be fixed.
+        *
+        * @return bool
+        */
+       public function isChange() {
+               $this->assertPrepared( __METHOD__ );
+               return $this->options['changed'];
+       }
+
+       /**
+        * Whether the page was a redirect before the edit.
+        *
+        * @return bool
+        */
+       public function wasRedirect() {
+               $this->assertHasPageState( __METHOD__ );
+
+               if ( $this->pageState['oldIsRedirect'] === null ) {
+                       /** @var RevisionRecord $rev */
+                       $rev = $this->pageState['oldRevision'];
+                       if ( $rev ) {
+                               $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
+                       } else {
+                               $this->pageState['oldIsRedirect'] = false;
+                       }
+               }
+
+               return $this->pageState['oldIsRedirect'];
+       }
+
+       /**
+        * Returns the slots of the target revision, after PST.
+        *
+        * @return RevisionSlots
+        */
+       public function getSlots() {
+               $this->assertPrepared( __METHOD__ );
+               return $this->pstContentSlots;
+       }
+
+       /**
+        * Returns the RevisionSlotsUpdate for this updater.
+        *
+        * @return RevisionSlotsUpdate
+        */
+       private function getRevisionSlotsUpdate() {
+               $this->assertPrepared( __METHOD__ );
+
+               if ( !$this->slotsUpdate ) {
+                       if ( !$this->revision ) {
+                               // This should not be possible: if assertPrepared() returns true,
+                               // at least one of $this->slotsUpdate or $this->revision should be set.
+                               throw new LogicException( 'No revision nor a slots update is known!' );
+                       }
+
+                       $old = $this->getOldRevision();
+                       $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
+                               $this->revision->getSlots(),
+                               $old ? $old->getSlots() : null
+                       );
+               }
+               return $this->slotsUpdate;
+       }
+
+       /**
+        * Returns the role names of the slots touched by the new revision,
+        * including removed roles.
+        *
+        * @return string[]
+        */
+       public function getTouchedSlotRoles() {
+               return $this->getRevisionSlotsUpdate()->getTouchedRoles();
+       }
+
+       /**
+        * Returns the role names of the slots modified by the new revision,
+        * not including removed roles.
+        *
+        * @return string[]
+        */
+       public function getModifiedSlotRoles() {
+               return $this->getRevisionSlotsUpdate()->getModifiedRoles();
+       }
+
+       /**
+        * Returns the role names of the slots removed by the new revision.
+        *
+        * @return string[]
+        */
+       public function getRemovedSlotRoles() {
+               return $this->getRevisionSlotsUpdate()->getRemovedRoles();
+       }
+
+       /**
+        * Prepare derived data updates targeting the given Revision.
+        *
+        * Calling this method requires the given revision to be present in the database.
+        * This may be right after a new revision has been created, or when re-generating
+        * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
+        * script.
+        *
+        * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+        *
+        * @note: Calling this method more than once with the same revision has no effect.
+        * $options are only used for the first call. Calling this method multiple times with
+        * different revisions will cause an exception.
+        *
+        * @note: If grabCurrentRevision() (or prepareContent()) has been called before
+        * calling this method, $revision->getParentRevision() has to refer to the revision that
+        * was the current revision at the time grabCurrentRevision() was called.
+        *
+        * @param RevisionRecord $revision
+        * @param array $options Array of options, following indexes are used:
+        * - changed: bool, whether the revision changed the content (default true)
+        * - created: bool, whether the revision created the page (default false)
+        * - moved: bool, whether the page was moved (default false)
+        * - restored: bool, whether the page was undeleted (default false)
+        * - oldrevision: Revision object for the pre-update revision (default null)
+        * - parseroutput: The canonical ParserOutput of $revision (default null)
+        * - triggeringuser: The user triggering the update (UserIdentity, default null)
+        * - oldredirect: bool, null, or string 'no-change' (default null):
+        *    - bool: whether the page was counted as a redirect before that
+        *      revision, only used in changed is true and created is false
+        *    - null or 'no-change': don't update the redirect status.
+        * - oldcountable: bool, null, or string 'no-change' (default null):
+        *    - bool: whether the page was counted as an article before that
+        *      revision, only used in changed is true and created is false
+        *    - null: if created is false, don't update the article count; if created
+        *      is true, do update the article count
+        *    - 'no-change': don't update the article count, ever
+        *
+        */
+       public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
+               Assert::parameter(
+                       !isset( $options['oldrevision'] )
+                       || $options['oldrevision'] instanceof Revision
+                       || $options['oldrevision'] instanceof RevisionRecord,
+                       '$options["oldrevision"]',
+                       'must be a RevisionRecord (or Revision)'
+               );
+               Assert::parameter(
+                       !isset( $options['parseroutput'] )
+                       || $options['parseroutput'] instanceof ParserOutput,
+                       '$options["parseroutput"]',
+                       'must be a ParserOutput'
+               );
+               Assert::parameter(
+                       !isset( $options['triggeringuser'] )
+                       || $options['triggeringuser'] instanceof UserIdentity,
+                       '$options["triggeringuser"]',
+                       'must be a UserIdentity'
+               );
+
+               if ( !$revision->getId() ) {
+                       throw new InvalidArgumentException(
+                               'Revision must have an ID set for it to be used with prepareUpdate()!'
+                       );
+               }
+
+               if ( $this->revision ) {
+                       if ( $this->revision->getId() === $revision->getId() ) {
+                               return; // nothing to do!
+                       } else {
+                               throw new LogicException(
+                                       'Trying to re-use DerivedPageDataUpdater with revision '
+                                       .$revision->getId()
+                                       . ', but it\'s already bound to revision '
+                                       . $this->revision->getId()
+                               );
+                       }
+               }
+
+               if ( $this->pstContentSlots
+                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               ) {
+                       throw new LogicException(
+                               'The Revision provided has mismatching content!'
+                       );
+               }
+
+               // Override fields defined in $this->options with values from $options.
+               $this->options = array_intersect_key( $options, $this->options ) + $this->options;
+
+               if ( isset( $this->pageState['oldId'] ) ) {
+                       $oldId = $this->pageState['oldId'];
+               } elseif ( isset( $this->options['oldrevision'] ) ) {
+                       /** @var Revision|RevisionRecord $oldRev */
+                       $oldRev = $this->options['oldrevision'];
+                       $oldId = $oldRev->getId();
+               } else {
+                       $oldId = $revision->getParentId();
+               }
+
+               if ( $oldId !== null ) {
+                       // XXX: what if $options['changed'] disagrees?
+                       // MovePage creates a dummy revision with changed = false!
+                       // We may want to explicitly distinguish between "no new revision" (null-edit)
+                       // and "new revision without new content" (dummy revision).
+
+                       if ( $oldId === $revision->getParentId() ) {
+                               // NOTE: this may still be a NullRevision!
+                               // New revision!
+                               $this->options['changed'] = true;
+                       } elseif ( $oldId === $revision->getId() ) {
+                               // Null-edit!
+                               $this->options['changed'] = false;
+                       } else {
+                               // This indicates that calling code has given us the wrong Revision object
+                               throw new LogicException(
+                                       'The Revision mismatches old revision ID: '
+                                       . 'Old ID is ' . $oldId
+                                       . ', parent ID is ' . $revision->getParentId()
+                                       . ', revision ID is ' . $revision->getId()
+                               );
+                       }
+               }
+
+               // If prepareContent() was used to generate the PST content (which is indicated by
+               // $this->slotsUpdate being set), and this is not a null-edit, then the given
+               // revision must have the acting user as the revision author. Otherwise, user
+               // signatures generated by PST would mismatch the user in the revision record.
+               if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
+                       $user = $revision->getUser();
+                       if ( !$this->user->equals( $user ) ) {
+                               throw new LogicException(
+                                       'The Revision provided has a mismatching actor: expected '
+                                       .$this->user->getName()
+                                       . ', got '
+                                       . $user->getName()
+                               );
+                       }
+               }
+
+               // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
+               // emulate the state of the page table before the edit, as good as we can.
+               if ( !$this->pageState ) {
+                       $this->pageState = [
+                               'oldIsRedirect' => isset( $this->options['oldredirect'] )
+                                       && is_bool( $this->options['oldredirect'] )
+                                               ? $this->options['oldredirect']
+                                               : null,
+                               'oldCountable' => isset( $this->options['oldcountable'] )
+                                       && is_bool( $this->options['oldcountable'] )
+                                               ? $this->options['oldcountable']
+                                               : null,
+                       ];
+
+                       if ( $this->options['changed'] ) {
+                               // The edit created a new revision
+                               $this->pageState['oldId'] = $revision->getParentId();
+
+                               if ( isset( $this->options['oldrevision'] ) ) {
+                                       $rev = $this->options['oldrevision'];
+                                       $this->pageState['oldRevision'] = $rev instanceof Revision
+                                               ? $rev->getRevisionRecord()
+                                               : $rev;
+                               }
+                       } else {
+                               // This is a null-edit, so the old revision IS the new revision!
+                               $this->pageState['oldId'] = $revision->getId();
+                               $this->pageState['oldRevision'] = $revision;
+                       }
+               }
+
+               // "created" is forced here
+               $this->options['created'] = ( $this->pageState['oldId'] === 0 );
+
+               $this->revision = $revision;
+               $this->pstContentSlots = $revision->getSlots();
+
+               $this->doTransition( 'has-revision' );
+
+               // NOTE: in case we have a User object, don't override with a UserIdentity.
+               // We already checked that $revision->getUser() mathces $this->user;
+               if ( !$this->user ) {
+                       $this->user = $revision->getUser( RevisionRecord::RAW );
+               }
+
+               // Prune any output that depends on the revision ID.
+               if ( $this->canonicalParserOutput ) {
+                       if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) {
+                               $this->canonicalParserOutput = null;
+                       }
+               } else {
+                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" );
+               }
+
+               if ( $this->slotsOutput ) {
+                       foreach ( $this->slotsOutput as $role => $prep ) {
+                               if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) {
+                                       unset( $this->slotsOutput[$role] );
+                               }
+                       }
+               } else {
+                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" );
+               }
+
+               // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation
+               $this->canonicalParserOptions = null;
+
+               // Avoid re-generating the canonical ParserOutput if it's known.
+               // We just trust that the caller is passing the correct ParserOutput!
+               if ( isset( $options['parseroutput'] ) ) {
+                       $this->canonicalParserOutput = $options['parseroutput'];
+               }
+
+               // TODO: optionally get ParserOutput from the ParserCache here.
+               // Move the logic used by RefreshLinksJob here!
+       }
+
+       /**
+        * @param ParserOutput $out
+        * @param string $method
+        * @return bool
+        */
+       private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) {
+               if ( $out->getFlag( 'vary-revision' ) ) {
+                       // XXX: Just keep the output if the speculative revision ID was correct, like below?
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision...\n"
+                       );
+                       return true;
+               } elseif ( $out->getFlag( 'vary-revision-id' )
+                       && $out->getSpeculativeRevIdUsed() !== $this->revision->getId()
+               ) {
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision-id with wrong ID...\n"
+                       );
+                       return true;
+               } elseif ( $out->getFlag( 'vary-user' )
+                       && !$this->options['changed']
+               ) {
+                       // When Alice makes a null-edit on top of Bob's edit,
+                       // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261.
+                       // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions,
+                       // and set setCurrentRevisionCallback to return the existing revision when appropriate.
+                       // See also the comment there [dk 2018-05]
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-user and is null-edit...\n"
+                       );
+                       return true;
+               } else {
+                       wfDebug( "$method: Keeping prepared output...\n" );
+                       return false;
+               }
+       }
+
+       /**
+        * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
+        * @return PreparedEdit
+        */
+       public function getPreparedEdit() {
+               $this->assertPrepared( __METHOD__ );
+
+               $slotsUpdate = $this->getRevisionSlotsUpdate();
+               $preparedEdit = new PreparedEdit();
+
+               $preparedEdit->popts = $this->getCanonicalParserOptions();
+               $preparedEdit->output = $this->getCanonicalParserOutput();
+               $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' );
+               $preparedEdit->newContent =
+                       $slotsUpdate->isModifiedSlot( 'main' )
+                       ? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
+                       : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this?
+               $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
+               $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
+               $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
+               $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
+
+               return $preparedEdit;
+       }
+
+       /**
+        * @return bool
+        */
+       private function isContentAccessible() {
+               // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable!
+               return $this->isContentPublic();
+       }
+
+       /**
+        * @param string $role
+        * @param bool $generateHtml
+        * @return ParserOutput
+        */
+       public function getSlotParserOutput( $role, $generateHtml = true ) {
+               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
+
+               $this->assertPrepared( __METHOD__ );
+
+               if ( isset( $this->slotsOutput[$role] ) ) {
+                       $entry = $this->slotsOutput[$role];
+
+                       if ( $entry->hasHtml || !$generateHtml ) {
+                               return $entry->output;
+                       }
+               }
+
+               if ( !$this->isContentAccessible() ) {
+                       // empty output
+                       $output = new ParserOutput();
+               } else {
+                       $content = $this->getRawContent( $role );
+
+                       $output = $content->getParserOutput(
+                               $this->getTitle(),
+                               $this->revision ? $this->revision->getId() : null,
+                               $this->getCanonicalParserOptions(),
+                               $generateHtml
+                       );
+               }
+
+               $this->slotsOutput[$role] = (object)[
+                       'output' => $output,
+                       'hasHtml' => $generateHtml,
+               ];
+
+               $output->setCacheTime( $this->getTimestampNow() );
+
+               return $output;
+       }
+
+       /**
+        * @return ParserOutput
+        */
+       public function getCanonicalParserOutput() {
+               if ( $this->canonicalParserOutput ) {
+                       return $this->canonicalParserOutput;
+               }
+
+               // TODO: MCR: logic for combining the output of multiple slot goes here!
+               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
+               $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' );
+
+               return $this->canonicalParserOutput;
+       }
+
+       /**
+        * @return ParserOptions
+        */
+       public function getCanonicalParserOptions() {
+               if ( $this->canonicalParserOptions ) {
+                       return $this->canonicalParserOptions;
+               }
+
+               // TODO: ParserOptions should *not* be controlled by the ContentHandler!
+               // See T190712 for how to fix this for Wikibase.
+               $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' );
+
+               //TODO: if $this->revision is not set but we already know that we pending update is a
+               // null-edit, we should probably use the page's current revision here.
+               // That would avoid the need for the !$this->options['changed'] branch in
+               // outputVariesOnRevisionMetaData [dk 2018-05]
+
+               if ( $this->revision ) {
+                       // Make sure we use the appropriate revision ID when generating output
+                       $title = $this->getTitle();
+                       $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback();
+                       $this->canonicalParserOptions->setCurrentRevisionCallback(
+                               function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) {
+                                       if ( $parserTitle->equals( $title ) ) {
+                                               $legacyRevision = new Revision( $this->revision );
+                                               return $legacyRevision;
+                                       } else {
+                                               return call_user_func( $oldCallback, $parserTitle, $parser );
+                                       }
+                               }
+                       );
+               } else {
+                       // NOTE: we only get here without READ_LATEST if called directly by application logic
+                       $dbIndex = $this->useMaster()
+                               ? DB_MASTER // use the best possible guess
+                               : DB_REPLICA; // T154554
+
+                       $this->canonicalParserOptions->setSpeculativeRevIdCallback(
+                               function () use ( $dbIndex ) {
+                                       // TODO: inject LoadBalancer!
+                                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+                                       // Use a fresh connection in order to see the latest data, by avoiding
+                                       // stale data from REPEATABLE-READ snapshots.
+                                       // HACK: But don't use a fresh connection in unit tests, since it would not have
+                                       // the fake tables. This should be handled by the LoadBalancer!
+                                       $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTO;
+                                       $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags );
+
+                                       return 1 + (int)$db->selectField(
+                                               'revision',
+                                               'MAX(rev_id)',
+                                               [],
+                                               __METHOD__
+                                       );
+                               }
+                       );
+               }
+
+               return $this->canonicalParserOptions;
+       }
+
+       /**
+        * @param bool $recursive
+        *
+        * @return DataUpdate[]
+        */
+       public function getSecondaryDataUpdates( $recursive = false ) {
+               // TODO: MCR: getSecondaryDataUpdates() needs a complete overhaul to avoid DataUpdates
+               // from different slots overwriting each other in the database. Plan:
+               // * replace direct calls to Content::getSecondaryDataUpdates() with calls to this method
+               // * Construct LinksUpdate here, on the combined ParserOutput, instead of in AbstractContent
+               //   for each slot.
+               // * Pass $slot into getSecondaryDataUpdates() - probably be introducing a new duplicate
+               //   version of this function in ContentHandler.
+               // * The new method gets the PreparedEdit, but no $recursive flag (that's for LinksUpdate)
+               // * Hack: call both the old and the new getSecondaryDataUpdates method here; Pass
+               //   the per-slot ParserOutput to the old method, for B/C.
+               // * Hack: If there is more than one slot, filter LinksUpdate from the DataUpdates
+               //   returned by getSecondaryDataUpdates, and use a LinksUpdated for the combined output
+               //   instead.
+               // * Call the SecondaryDataUpdates hook here (or kill it - its signature doesn't make sense)
+
+               $content = $this->getSlots()->getContent( 'main' );
+
+               // NOTE: $output is the combined output, to be shown in the default view.
+               $output = $this->getCanonicalParserOutput();
+
+               $updates = $content->getSecondaryDataUpdates(
+                       $this->getTitle(), null, $recursive, $output
+               );
+
+               return $updates;
+       }
+
+       /**
+        * Do standard updates after page edit, purge, or import.
+        * Update links tables, site stats, search index, title cache, message cache, etc.
+        * Purges pages that depend on this page when appropriate.
+        * With a 10% chance, triggers pruning the recent changes table.
+        *
+        * @note prepareUpdate() must be called before calling this method!
+        *
+        * MCR migration note: this replaces WikiPage::doEditUpdates.
+        */
+       public function doUpdates() {
+               $this->assertTransition( 'done' );
+
+               // TODO: move logic into a PageEventEmitter service
+
+               $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
+
+               // NOTE: this may trigger the first parsing of the new content after an edit (when not
+               // using pre-generated stashed output).
+               // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
+               // to be perform post-send. The client could already follow a HTTP redirect to the
+               // page view, but would then have to wait for a response until rendering is complete.
+               $output = $this->getCanonicalParserOutput();
+
+               // Save it to the parser cache.
+               // Make sure the cache time matches page_touched to avoid double parsing.
+               $this->parserCache->save(
+                       $output, $wikiPage, $this->getCanonicalParserOptions(),
+                       $this->revision->getTimestamp(),  $this->revision->getId()
+               );
+
+               $legacyUser = User::newFromIdentity( $this->user );
+               $legacyRevision = new Revision( $this->revision );
+
+               // Update the links tables and other secondary data
+               $recursive = $this->options['changed']; // T52785
+               $updates = $this->getSecondaryDataUpdates( $recursive );
+
+               foreach ( $updates as $update ) {
+                       // TODO: make an $option field for the cause
+                       $update->setCause( 'edit-page', $this->user->getName() );
+                       if ( $update instanceof LinksUpdate ) {
+                               $update->setRevision( $legacyRevision );
+
+                               if ( !empty( $this->options['triggeringuser'] ) ) {
+                                       /** @var UserIdentity|User $triggeringUser */
+                                       $triggeringUser = $this->options['triggeringuser'];
+                                       if ( !$triggeringUser instanceof User ) {
+                                               $triggeringUser = User::newFromIdentity( $triggeringUser );
+                                       }
+
+                                       $update->setTriggeringUser( $triggeringUser );
+                               }
+                       }
+                       DeferredUpdates::addUpdate( $update );
+               }
+
+               // TODO: MCR: check if *any* changed slot supports categories!
+               if ( $this->rcWatchCategoryMembership
+                       && $this->getContentHandler( 'main' )->supportsCategories() === true
+                       && ( $this->options['changed'] || $this->options['created'] )
+                       && !$this->options['restored']
+               ) {
+                       // Note: jobs are pushed after deferred updates, so the job should be able to see
+                       // the recent change entry (also done via deferred updates) and carry over any
+                       // bot/deletion/IP flags, ect.
+                       $this->jobQueueGroup->lazyPush(
+                               new CategoryMembershipChangeJob(
+                                       $this->getTitle(),
+                                       [
+                                               'pageId' => $this->getPageId(),
+                                               'revTimestamp' => $this->revision->getTimestamp(),
+                                       ]
+                               )
+                       );
+               }
+
+               // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+               $editInfo = $this->getPreparedEdit();
+               Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
+
+               // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+               if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
+                       // Flush old entries from the `recentchanges` table
+                       if ( mt_rand( 0, 9 ) == 0 ) {
+                               $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
+                       }
+               }
+
+               $id = $this->getPageId();
+               $title = $this->getTitle();
+               $dbKey = $title->getPrefixedDBkey();
+               $shortTitle = $title->getDBkey();
+
+               if ( !$title->exists() ) {
+                       wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out\n" );
+
+                       $this->doTransition( 'done' );
+                       return;
+               }
+
+               if ( $this->options['oldcountable'] === 'no-change' ||
+                       ( !$this->options['changed'] && !$this->options['moved'] )
+               ) {
+                       $good = 0;
+               } elseif ( $this->options['created'] ) {
+                       $good = (int)$this->isCountable();
+               } elseif ( $this->options['oldcountable'] !== null ) {
+                       $good = (int)$this->isCountable()
+                               - (int)$this->options['oldcountable'];
+               } else {
+                       $good = 0;
+               }
+               $edits = $this->options['changed'] ? 1 : 0;
+               $pages = $this->options['created'] ? 1 : 0;
+
+               DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
+                       [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
+               ) );
+
+               // TODO: make search infrastructure aware of slots!
+               $mainSlot = $this->revision->getSlot( 'main' );
+               if ( !$mainSlot->isInherited() && $this->isContentPublic() ) {
+                       DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
+               }
+
+               // If this is another user's talk page, update newtalk.
+               // Don't do this if $options['changed'] = false (null-edits) nor if
+               // it's a minor edit and the user making the edit doesn't generate notifications for those.
+               if ( $this->options['changed']
+                       && $title->getNamespace() == NS_USER_TALK
+                       && $shortTitle != $legacyUser->getTitleKey()
+                       && !( $this->revision->isMinor() && $legacyUser->isAllowed( 'nominornewtalk' ) )
+               ) {
+                       $recipient = User::newFromName( $shortTitle, false );
+                       if ( !$recipient ) {
+                               wfDebug( __METHOD__ . ": invalid username\n" );
+                       } else {
+                               // Allow extensions to prevent user notification
+                               // when a new message is added to their talk page
+                               // TODO: replace legacy hook!  Use a listener on PageEventEmitter instead!
+                               if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
+                                       if ( User::isIP( $shortTitle ) ) {
+                                               // An anonymous user
+                                               $recipient->setNewtalk( true, $legacyRevision );
+                                       } elseif ( $recipient->isLoggedIn() ) {
+                                               $recipient->setNewtalk( true, $legacyRevision );
+                                       } else {
+                                               wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
+                                       }
+                               }
+                       }
+               }
+
+               if ( $title->getNamespace() == NS_MEDIAWIKI
+                       && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
+               ) {
+                       $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null;
+
+                       $this->messageCache->updateMessageOverride( $title, $mainContent );
+               }
+
+               // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
+               if ( $this->options['created'] ) {
+                       WikiPage::onArticleCreate( $title );
+               } elseif ( $this->options['changed'] ) { // T52785
+                       WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
+               }
+
+               $oldRevision = $this->getOldRevision();
+               $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
+
+               // TODO: In the wiring, register a listener for this on the new PageEventEmitter
+               ResourceLoaderWikiModule::invalidateModuleCache(
+                       $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId()
+               );
+
+               $this->doTransition( 'done' );
+       }
+
+}
index a259ae0..1aa1165 100644 (file)
@@ -44,26 +44,14 @@ class MutableRevisionRecord extends RevisionRecord {
         * the new revision will act as a null-revision.
         *
         * @param RevisionRecord $parent
-        * @param CommentStoreComment $comment
-        * @param UserIdentity $user
-        * @param string $timestamp
         *
         * @return MutableRevisionRecord
         */
-       public static function newFromParentRevision(
-               RevisionRecord $parent,
-               CommentStoreComment $comment,
-               UserIdentity $user,
-               $timestamp
-       ) {
+       public static function newFromParentRevision( RevisionRecord $parent ) {
                // TODO: ideally, we wouldn't need a Title here
                $title = Title::newFromLinkTarget( $parent->getPageAsLinkTarget() );
                $rev = new MutableRevisionRecord( $title, $parent->getWikiId() );
 
-               $rev->setComment( $comment );
-               $rev->setUser( $user );
-               $rev->setTimestamp( $timestamp );
-
                foreach ( $parent->getSlotRoles() as $role ) {
                        $slot = $parent->getSlot( $role, self::RAW );
                        $rev->inheritSlot( $slot );
@@ -140,8 +128,8 @@ class MutableRevisionRecord extends RevisionRecord {
         * @param SlotRecord $parentSlot
         */
        public function inheritSlot( SlotRecord $parentSlot ) {
-               $slot = SlotRecord::newInherited( $parentSlot );
-               $this->setSlot( $slot );
+               $this->mSlots->inheritSlot( $parentSlot );
+               $this->resetAggregateValues();
        }
 
        /**
@@ -180,6 +168,15 @@ class MutableRevisionRecord extends RevisionRecord {
                $this->resetAggregateValues();
        }
 
+       /**
+        * Applies the given update to the slots of this revision.
+        *
+        * @param RevisionSlotsUpdate $update
+        */
+       public function applyUpdate( RevisionSlotsUpdate $update ) {
+               $update->apply( $this->mSlots );
+       }
+
        /**
         * @param CommentStoreComment $comment
         */
index 4cc3730..df94964 100644 (file)
@@ -60,8 +60,6 @@ class MutableRevisionSlots extends RevisionSlots {
         * Sets the given slot.
         * If a slot with the same role is already present, it is replaced.
         *
-        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
-        *
         * @param SlotRecord $slot
         */
        public function setSlot( SlotRecord $slot ) {
@@ -74,10 +72,18 @@ class MutableRevisionSlots extends RevisionSlots {
        }
 
        /**
-        * Sets the content for the slot with the given role.
+        * Sets the given slot to an inherited version of $slot.
         * If a slot with the same role is already present, it is replaced.
         *
-        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        * @param SlotRecord $slot
+        */
+       public function inheritSlot( SlotRecord $slot ) {
+               $this->setSlot( SlotRecord::newInherited( $slot ) );
+       }
+
+       /**
+        * Sets the content for the slot with the given role.
+        * If a slot with the same role is already present, it is replaced.
         *
         * @param string $role
         * @param Content $content
@@ -90,8 +96,6 @@ class MutableRevisionSlots extends RevisionSlots {
        /**
         * Remove the slot for the given role, discontinue the corresponding stream.
         *
-        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
-        *
         * @param string $role
         */
        public function removeSlot( $role ) {
diff --git a/includes/Storage/PageUpdateException.php b/includes/Storage/PageUpdateException.php
new file mode 100644 (file)
index 0000000..d87374a
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Exception representing a failure to update a page entry.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use RuntimeException;
+
+/**
+ * Exception representing a failure to update a page entry.
+ *
+ * @since 1.32
+ */
+class PageUpdateException extends RuntimeException {
+
+}
diff --git a/includes/Storage/PageUpdater.php b/includes/Storage/PageUpdater.php
new file mode 100644 (file)
index 0000000..10caac4
--- /dev/null
@@ -0,0 +1,1231 @@
+<?php
+/**
+ * Controller-like object for creating and updating pages by creating new revisions.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @author Daniel Kinzler
+ */
+
+namespace MediaWiki\Storage;
+
+use AtomicSectionUpdate;
+use ChangeTags;
+use CommentStoreComment;
+use Content;
+use ContentHandler;
+use DeferredUpdates;
+use Hooks;
+use InvalidArgumentException;
+use LogicException;
+use ManualLogEntry;
+use MediaWiki\Linker\LinkTarget;
+use MWException;
+use RecentChange;
+use Revision;
+use RuntimeException;
+use Status;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\LoadBalancer;
+use WikiPage;
+
+/**
+ * Controller-like object for creating and updating pages by creating new revisions.
+ *
+ * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
+ * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
+ * This allows application logic to safely perform edit conflict resolution using the parent
+ * revision's content.
+ *
+ * @see docs/pageupdater.txt for more information.
+ *
+ * MCR migration note: this replaces the relevant methods in WikiPage.
+ *
+ * @since 1.32
+ * @ingroup Page
+ */
+class PageUpdater {
+
+       /**
+        * @var User
+        */
+       private $user;
+
+       /**
+        * @var WikiPage
+        */
+       private $wikiPage;
+
+       /**
+        * @var DerivedPageDataUpdater
+        */
+       private $derivedDataUpdater;
+
+       /**
+        * @var LoadBalancer
+        */
+       private $loadBalancer;
+
+       /**
+        * @var RevisionStore
+        */
+       private $revisionStore;
+
+       /**
+        * @var boolean see $wgUseAutomaticEditSummaries
+        * @see $wgUseAutomaticEditSummaries
+        */
+       private $useAutomaticEditSummaries = true;
+
+       /**
+        * @var int the RC patrol status the new revision should be marked with.
+        */
+       private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
+
+       /**
+        * @var bool whether to create a log entry for new page creations.
+        */
+       private $usePageCreationLog = true;
+
+       /**
+        * @var boolean see $wgAjaxEditStash
+        */
+       private $ajaxEditStash = true;
+
+       /**
+        * The ID of the logical base revision the content of the new revision is based on.
+        * Not to be confused with the immediate parent revision (the current revision before the
+        * new revision is created).
+        * The base revision is the last revision known to the client, while the parent revision
+        * is determined on the server by grabParentRevision().
+        *
+        * @var bool|int
+        */
+       private $baseRevId = false;
+
+       /**
+        * @var array
+        */
+       private $tags = [];
+
+       /**
+        * @var int
+        */
+       private $undidRevId = 0;
+
+       /**
+        * @var RevisionSlotsUpdate
+        */
+       private $slotsUpdate;
+
+       /**
+        * @var Status|null
+        */
+       private $status = null;
+
+       /**
+        * @param User $user
+        * @param WikiPage $wikiPage
+        * @param DerivedPageDataUpdater $derivedDataUpdater
+        * @param LoadBalancer $loadBalancer
+        * @param RevisionStore $revisionStore
+        */
+       public function __construct(
+               User $user,
+               WikiPage $wikiPage,
+               DerivedPageDataUpdater $derivedDataUpdater,
+               LoadBalancer $loadBalancer,
+               RevisionStore $revisionStore
+       ) {
+               $this->user = $user;
+               $this->wikiPage = $wikiPage;
+               $this->derivedDataUpdater = $derivedDataUpdater;
+
+               $this->loadBalancer = $loadBalancer;
+               $this->revisionStore = $revisionStore;
+
+               $this->slotsUpdate = new RevisionSlotsUpdate();
+       }
+
+       /**
+        * Can be used to enable or disable automatic summaries that are applied to certain kinds of
+        * changes, like completely blanking a page.
+        *
+        * @param bool $useAutomaticEditSummaries
+        * @see $wgUseAutomaticEditSummaries
+        */
+       public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
+               $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
+       }
+
+       /**
+        * Sets the "patrolled" status of the edit.
+        * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
+        *
+        * @see $wgUseRCPatrol
+        * @see $wgUseNPPatrol
+        *
+        * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
+        */
+       public function setRcPatrolStatus( $status ) {
+               $this->rcPatrolStatus = $status;
+       }
+
+       /**
+        * Whether to create a log entry for new page creations.
+        *
+        * @see $wgPageCreationLog
+        *
+        * @param bool $use
+        */
+       public function setUsePageCreationLog( $use ) {
+               $this->usePageCreationLog = $use;
+       }
+
+       /**
+        * @param bool $ajaxEditStash
+        * @see $wgAjaxEditStash
+        */
+       public function setAjaxEditStash( $ajaxEditStash ) {
+               $this->ajaxEditStash = $ajaxEditStash;
+       }
+
+       private function getWikiId() {
+               return false; // TODO: get from RevisionStore!
+       }
+
+       /**
+        * @param int $mode DB_MASTER or DB_REPLICA
+        *
+        * @return DBConnRef
+        */
+       private function getDBConnectionRef( $mode ) {
+               return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() );
+       }
+
+       /**
+        * @return LinkTarget
+        */
+       private function getLinkTarget() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage->getTitle();
+       }
+
+       /**
+        * @return Title
+        */
+       private function getTitle() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage->getTitle();
+       }
+
+       /**
+        * @return WikiPage
+        */
+       private function getWikiPage() {
+               // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+               return $this->wikiPage;
+       }
+
+       /**
+        * Checks whether this update conflicts with another update performed since the specified base
+        * revision. A user level "edit conflict" is detected when the base revision known to the client
+        * and specified via setBaseRevisionId() is not the ID of the current revision before the
+        * update. If setBaseRevisionId() was not called, this method always returns false.
+        *
+        * Note that an update expected to be based on a non-existing page will have base revision ID 0,
+        * and is considered to have a conflict if a current revision exists (that is, the page was
+        * created since the base revision was determined by the client).
+        *
+        * This method returning true indicates to calling code that edit conflict resolution should
+        * be applied before saving any data. It does not prevent the update from being performed, and
+        * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
+        * A "late" conflict is a CAS failure caused by an update being performed concurrently, between
+        * the time grabParentRevision() was called and the time saveRevision() trying to insert the
+        * new revision.
+        *
+        * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
+        * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
+        * This method calls grabParentRevision(), and thus causes the expected parent revision
+        * for the update to be fixed to the page's current revision at this point in time.
+        * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
+        * will fail with the "edit-conflict" status if the current revision of the page changes after
+        * hasEditConflict() was called and before saveRevision() could insert a new revision.
+        *
+        * @see grabParentRevision()
+        *
+        * @return bool
+        */
+       public function hasEditConflict() {
+               $baseId = $this->getBaseRevisionId();
+               if ( $baseId === false ) {
+                       return false;
+               }
+
+               $parent = $this->grabParentRevision();
+               $parentId = $parent ? $parent->getId() : 0;
+
+               return $parentId !== $baseId;
+       }
+
+       /**
+        * Returns the revision that was the page's current revision when grabParentRevision()
+        * was first called. This revision is the expected parent revision of the update, and will be
+        * recorded as the new revision's parent revision (unless no new revision is created because
+        * the content was not changed).
+        *
+        * This method MUST not be called after saveRevision() was called!
+        *
+        * The current revision determined by the first call to this methods effectively acts a
+        * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
+        * concurrent updates created a new revision.
+        *
+        * Application code should call this method before applying transformations to the new
+        * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
+        * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
+        * updates.
+        *
+        * @see DerivedPageDataUpdater::grabCurrentRevision()
+        *
+        * @note The expected parent revision is not to be confused with the logical base revision.
+        * The base revision is specified by the client, the parent revision is determined from the
+        * database. If base revision and parent revision are not the same, the updates is considered
+        * to require edit conflict resolution.
+        *
+        * @throws LogicException if called after saveRevision().
+        * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
+        */
+       public function grabParentRevision() {
+               return $this->derivedDataUpdater->grabCurrentRevision();
+       }
+
+       /**
+        * @return string
+        */
+       private function getTimestampNow() {
+               // TODO: allow an override to be injected for testing
+               return wfTimestampNow();
+       }
+
+       /**
+        * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
+        * This also performs sanity checks against the base revision specified via setBaseRevisionId().
+        *
+        * @param int $flags
+        * @return int Updated $flags
+        */
+       private function checkFlags( $flags ) {
+               if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
+                       if ( $this->baseRevId === false ) {
+                               $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
+                       } else {
+                               $flags |= ( $this->baseRevId > 0 ) ? EDIT_UPDATE : EDIT_NEW;
+                       }
+               }
+
+               return $flags;
+       }
+
+       /**
+        * Set the new content for the given slot role
+        *
+        * @param string $role A slot role name (such as "main")
+        * @param Content $content
+        */
+       public function setContent( $role, Content $content ) {
+               // TODO: MCR: check the role and the content's model against the list of supported
+               // roles, see T194046.
+
+               $this->slotsUpdate->modifyContent( $role, $content );
+       }
+
+       /**
+        * Explicitly inherit a slot from some earlier revision.
+        *
+        * The primary use case for this is rollbacks, when slots are to be inherited from
+        * the rollback target, overriding the content from the parent revision (which is the
+        * revision being rolled back).
+        *
+        * This should typically not be used to inherit slots from the parent revision, which
+        * happens implicitly. Using this method causes the given slot to be treated as "modified"
+        * during revision creation, even if it has the same content as in the parent revision.
+        *
+        * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
+        *        by the new revision.
+        */
+       public function inheritSlot( SlotRecord $originalSlot ) {
+               // NOTE: this slot is inherited from some other revision, but it's
+               // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
+               // since it's not implicitly inherited from the parent revision.
+               $inheritedSlot = SlotRecord::newInherited( $originalSlot );
+               $this->slotsUpdate->modifySlot( $inheritedSlot );
+       }
+
+       /**
+        * Removes the slot with the given role.
+        *
+        * This discontinues the "stream" of slots with this role on the page,
+        * preventing the new revision, and any subsequent revisions, from
+        * inheriting the slot with this role.
+        *
+        * @param string $role A slot role name (but not "main")
+        */
+       public function removeSlot( $role ) {
+               if ( $role === 'main' ) {
+                       throw new InvalidArgumentException( 'Cannot remove the main slot!' );
+               }
+
+               $this->slotsUpdate->removeSlot( $role );
+       }
+
+       /**
+        * Returns the ID of the logical base revision of the update. Not to be confused with the
+        * immediate parent revision. The base revision is set via setBaseRevisionId(),
+        * the parent revision is determined by grabParentRevision().
+        *
+        * Application may use this information to detect user level edit conflicts. Edit conflicts
+        * can be resolved by performing a 3-way merge, using the revision returned by this method as
+        * the common base of the conflicting revisions, namely the new revision being saved,
+        * and the revision returned by grabParentRevision().
+        *
+        * @return bool|int The ID of the base revision, 0 if the base is a non-existing page, false
+        *         if no base revision was specified.
+        */
+       public function getBaseRevisionId() {
+               return $this->baseRevId;
+       }
+
+       /**
+        * Sets the ID of the revision the content of this update is based on, if any.
+        * The base revision ID is not to be confused with the new revision's parent revision:
+        * the parent revision is the page's current revision immediately before the new revision
+        * is created; the base revision indicates what revision the client based the content of
+        * the new revision on. If base revision and parent revision are not the same, the update is
+        * considered to require edit conflict resolution.
+        *
+        * @param int|bool $baseRevId The ID of the base revision, or 0 if the update is expected to be
+        *        performed on a non-existing page. false can be used to indicate that the caller
+        *        doesn't care about the base revision.
+        */
+       public function setBaseRevisionId( $baseRevId ) {
+               Assert::parameterType( 'integer|boolean', $baseRevId, '$baseRevId' );
+               $this->baseRevId = $baseRevId;
+       }
+
+       /**
+        * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
+        * undone by this edit.
+        *
+        * @return int
+        */
+       public function getUndidRevisionId() {
+               return $this->undidRevId;
+       }
+
+       /**
+        * Sets the ID of revision that was undone by the present update.
+        * This is used with the "undo" action, and is expected to hold the oldest revision ID
+        * in case more then one revision is being undone.
+        *
+        * @param int $undidRevId
+        */
+       public function setUndidRevisionId( $undidRevId ) {
+               Assert::parameterType( 'integer', $undidRevId, '$undidRevId' );
+               $this->undidRevId = $undidRevId;
+       }
+
+       /**
+        * Sets a tag to apply to this update.
+        * Callers are responsible for permission checks,
+        * using ChangeTags::canAddTagsAccompanyingChange.
+        * @param string $tag
+        */
+       public function addTag( $tag ) {
+               Assert::parameterType( 'string', $tag, '$tag' );
+               $this->tags[] = trim( $tag );
+       }
+
+       /**
+        * Sets tags to apply to this update.
+        * Callers are responsible for permission checks,
+        * using ChangeTags::canAddTagsAccompanyingChange.
+        * @param string[] $tags
+        */
+       public function addTags( array $tags ) {
+               Assert::parameterElementType( 'string', $tags, '$tags' );
+               foreach ( $tags as $tag ) {
+                       $this->addTag( $tag );
+               }
+       }
+
+       /**
+        * Returns the list of tags set using the addTag() method.
+        *
+        * @return string[]
+        */
+       public function getExplicitTags() {
+               return $this->tags;
+       }
+
+       /**
+        * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
+        * @return string[]
+        */
+       private function computeEffectiveTags( $flags ) {
+               $tags = $this->tags;
+
+               foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
+                       $old_content = $this->getParentContent( $role );
+
+                       $handler = $this->getContentHandler( $role );
+                       $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
+
+                       // TODO: MCR: Do this for all slots. Also add tags for removing roles!
+                       $tag = $handler->getChangeTag( $old_content, $content, $flags );
+                       // If there is no applicable tag, null is returned, so we need to check
+                       if ( $tag ) {
+                               $tags[] = $tag;
+                       }
+               }
+
+               // Check for undo tag
+               if ( $this->undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
+                       $tags[] = 'mw-undo';
+               }
+
+               return array_unique( $tags );
+       }
+
+       /**
+        * Returns the content of the given slot of the parent revision, with no audience checks applied.
+        * If there is no parent revision or the slot is not defined, this returns null.
+        *
+        * @param string $role slot role name
+        * @return Content|null
+        */
+       private function getParentContent( $role ) {
+               $parent = $this->grabParentRevision();
+
+               if ( $parent && $parent->hasSlot( $role ) ) {
+                       return $parent->getContent( $role, RevisionRecord::RAW );
+               }
+
+               return null;
+       }
+
+       /**
+        * @param string $role slot role name
+        * @return ContentHandler
+        */
+       private function getContentHandler( $role ) {
+               // TODO: inject something like a ContentHandlerRegistry
+               if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
+                       $slot = $this->slotsUpdate->getModifiedSlot( $role );
+               } else {
+                       $parent = $this->grabParentRevision();
+
+                       if ( $parent ) {
+                               $slot = $parent->getSlot( $role, RevisionRecord::RAW );
+                       } else {
+                               throw new RevisionAccessException( 'No such slot: ' . $role );
+                       }
+               }
+
+               return ContentHandler::getForModelID( $slot->getModel() );
+       }
+
+       /**
+        * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
+        *
+        * @return CommentStoreComment
+        */
+       private function makeAutoSummary( $flags ) {
+               if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) {
+                       return CommentStoreComment::newUnsavedComment( '' );
+               }
+
+               // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
+               // TODO: combine auto-summaries for multiple slots!
+               // XXX: this logic should not be in the storage layer!
+               $roles = $this->slotsUpdate->getModifiedRoles();
+               $role = reset( $roles );
+
+               if ( $role === false ) {
+                       return CommentStoreComment::newUnsavedComment( '' );
+               }
+
+               $handler = $this->getContentHandler( $role );
+               $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
+               $old_content = $this->getParentContent( $role );
+               $summary = $handler->getAutosummary( $old_content, $content, $flags );
+
+               return CommentStoreComment::newUnsavedComment( $summary );
+       }
+
+       /**
+        * Change an existing article or create a new article. Updates RC and all necessary caches,
+        * optionally via the deferred update array. This does not check user permissions.
+        *
+        * It is guaranteed that saveRevision() will fail if the current revision of the page
+        * changes after grabParentRevision() was called and before saveRevision() can insert
+        * a new revision, as per the CAS mechanism described above.
+        *
+        * However, the actual parent revision is allowed to be different from the revision set
+        * with setBaseRevisionId(). The caller is responsible for checking this via
+        * hasEditConflict() and adjusting the content of the new revision accordingly,
+        * using a 3-way-merge if desired.
+        *
+        * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
+        * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
+        *
+        * @param CommentStoreComment $summary Edit summary
+        * @param int $flags Bitfield:
+        *      EDIT_NEW
+        *          Create a new page, or fail with "edit-already-exists" if the page exists.
+        *      EDIT_UPDATE
+        *          Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
+        *      EDIT_MINOR
+        *          Mark this revision as minor
+        *      EDIT_SUPPRESS_RC
+        *          Do not log the change in recentchanges
+        *      EDIT_FORCE_BOT
+        *          Mark the revision as automated ("bot edit")
+        *      EDIT_AUTOSUMMARY
+        *          Fill in blank summaries with generated text where possible
+        *      EDIT_INTERNAL
+        *          Signal that the page retrieve/save cycle happened entirely in this request.
+        *
+        * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
+        * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
+        * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
+        * was unexpectedly created or deleted while revision creation is in progress. This can be
+        * viewed as part of the CAS mechanism described above.
+        *
+        * @return RevisionRecord|null The new revision, or null if no new revision was created due
+        *         to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
+        *         to determine the outcome of the revision creation.
+        *
+        * @throws MWException
+        * @throws RuntimeException
+        */
+       public function saveRevision( CommentStoreComment $summary, $flags = 0 ) {
+               // Defend against mistakes caused by differences with the
+               // signature of WikiPage::doEditContent.
+               Assert::parameterType( 'integer', $flags, '$flags' );
+               Assert::parameterType( 'CommentStoreComment', $summary, '$summary' );
+
+               if ( $this->wasCommitted() ) {
+                       throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
+               }
+
+               // Low-level sanity check
+               if ( $this->getLinkTarget()->getText() === '' ) {
+                       throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
+               }
+
+               // TODO: MCR: check the role and the content's model against the list of supported
+               // and required roles, see T194046.
+
+               // Make sure the given content type is allowed for this page
+               // TODO: decide: Extend check to other slots? Consider the role in check? [PageType]
+               $mainContentHandler = $this->getContentHandler( 'main' );
+               if ( !$mainContentHandler->canBeUsedOn( $this->getTitle() ) ) {
+                       $this->status = Status::newFatal( 'content-not-allowed-here',
+                               ContentHandler::getLocalizedName( $mainContentHandler->getModelID() ),
+                               $this->getTitle()->getPrefixedText()
+                       );
+                       return null;
+               }
+
+               // Load the data from the master database if needed. Needed to check flags.
+               // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
+               // wasn't called yet. If the page is modified by another process before we are done with
+               // it, this method must fail (with status 'edit-conflict')!
+               // NOTE: The actual parent revision may be different from $this->baseRevisionId.
+               // The caller is responsible for checking this via hasEditConflict and adjusting the
+               // content of the new revision accordingly, using a 3-way-merge.
+               $this->grabParentRevision();
+               $flags = $this->checkFlags( $flags );
+
+               // Avoid statsd noise and wasted cycles check the edit stash (T136678)
+               if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
+                       $useStashed = false;
+               } else {
+                       $useStashed = $this->ajaxEditStash;
+               }
+
+               // TODO: use this only for the legacy hook, and only if something uses the legacy hook
+               $wikiPage = $this->getWikiPage();
+
+               $user = $this->user;
+
+               // Prepare the update. This performs PST and generates the canonical ParserOutput.
+               $this->derivedDataUpdater->prepareContent(
+                       $this->user,
+                       $this->slotsUpdate,
+                       $useStashed
+               );
+
+               // TODO: don't force initialization here!
+               // This is a hack to work around the fact that late initialization of the ParserOutput
+               // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
+               // actual problem, or is just an issue with the test setup, remains to be determined
+               // [dk, 2018-03].
+               // Anomie said in 2018-03:
+               /*
+                       I suspect that what's breaking is this:
+
+                       The old version of WikiPage::doEditContent() called prepareContentForEdit() which
+                       generated the ParserOutput right then, so when doEditUpdates() gets called from the
+                       DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
+                       there's a comment there that says "Get the pre-save transform content and final
+                       parser output".
+                       The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
+                       saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
+                       PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
+                       Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
+                       scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
+
+                       And the order of operations in that Flow test is presumably:
+
+                       - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
+                       processing the DeferredUpdate.
+                       - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
+                       - Then, during the course of doing that test, a $db->commit() results in the
+                       DeferredUpdates being run.
+                */
+               $this->derivedDataUpdater->getCanonicalParserOutput();
+
+               $mainContent = $this->derivedDataUpdater->getSlots()->getContent( 'main' );
+
+               // Trigger pre-save hook (using provided edit summary)
+               $hookStatus = Status::newGood( [] );
+               // TODO: replace legacy hook!
+               // TODO: avoid pass-by-reference, see T193950
+               $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
+                       $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
+               // Check if the hook rejected the attempted save
+               if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
+                       if ( $hookStatus->isOK() ) {
+                               // Hook returned false but didn't call fatal(); use generic message
+                               $hookStatus->fatal( 'edit-hook-aborted' );
+                       }
+
+                       $this->status = $hookStatus;
+                       return null;
+               }
+
+               // Provide autosummaries if one is not provided and autosummaries are enabled
+               // XXX: $summary == null seems logical, but the empty string may actually come from the user
+               // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
+               if ( $summary->text === '' && $summary->data === null ) {
+                       $summary = $this->makeAutoSummary( $flags );
+               }
+
+               // Actually create the revision and create/update the page.
+               // Do NOT yet set $this->status!
+               if ( $flags & EDIT_UPDATE ) {
+                       $status = $this->doModify( $summary, $this->user, $flags );
+               } else {
+                       $status = $this->doCreate( $summary, $this->user, $flags );
+               }
+
+               // Promote user to any groups they meet the criteria for
+               DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+                       $user->addAutopromoteOnceGroups( 'onEdit' );
+                       $user->addAutopromoteOnceGroups( 'onView' ); // b/c
+               } );
+
+               // NOTE: set $this->status only after all hooks have been called,
+               // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
+               $this->status = $status;
+
+               // TODO: replace bad status with Exceptions!
+               return ( $this->status && $this->status->isOK() )
+                       ? $this->status->value['revision-record']
+                       : null;
+       }
+
+       /**
+        * Whether saveRevision() has been called on this instance
+        *
+        * @return bool
+        */
+       public function wasCommitted() {
+               return $this->status !== null;
+       }
+
+       /**
+        * The Status object indicating whether saveRevision() was successful, or null if
+        * saveRevision() was not yet called on this instance.
+        *
+        * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
+        * soon.
+        *
+        * Possible status errors:
+        *     edit-hook-aborted: The ArticleSave hook aborted the update but didn't
+        *       set the fatal flag of $status.
+        *     edit-gone-missing: In update mode, but the article didn't exist.
+        *     edit-conflict: In update mode, the article changed unexpectedly.
+        *     edit-no-change: Warning that the text was the same as before.
+        *     edit-already-exists: In creation mode, but the article already exists.
+        *
+        *  Extensions may define additional errors.
+        *
+        *  $return->value will contain an associative array with members as follows:
+        *     new: Boolean indicating if the function attempted to create a new article.
+        *     revision: The revision object for the inserted revision, or null.
+        *
+        * @return null|Status
+        */
+       public function getStatus() {
+               return $this->status;
+       }
+
+       /**
+        * Whether saveRevision() completed successfully
+        *
+        * @return bool
+        */
+       public function wasSuccessful() {
+               return $this->status && $this->status->isOK();
+       }
+
+       /**
+        * Whether saveRevision() was called and created a new page.
+        *
+        * @return bool
+        */
+       public function isNew() {
+               return $this->status && $this->status->isOK() && $this->status->value['new'];
+       }
+
+       /**
+        * Whether saveRevision() did not create a revision because the content didn't change
+        * (null-edit). Whether the content changed or not is determined by
+        * DerivedPageDataUpdater::isChange().
+        *
+        * @return bool
+        */
+       public function isUnchanged() {
+               return $this->status
+                       && $this->status->isOK()
+                       && $this->status->value['revision-record'] === null;
+       }
+
+       /**
+        * The new revision created by saveRevision(), or null if saveRevision() has not yet been
+        * called, failed, or did not create a new revision because the content did not change.
+        *
+        * @return RevisionRecord|null
+        */
+       public function getNewRevision() {
+               return ( $this->status && $this->status->isOK() )
+                       ? $this->status->value['revision-record']
+                       : null;
+       }
+
+       /**
+        * Constructs a MutableRevisionRecord based on the Content prepared by the
+        * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
+        * with PST applied, and removing discontinued slots.
+        *
+        * This calls Content::prepareSave() to verify that the slot content can be saved.
+        * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
+        *
+        * @param CommentStoreComment $comment
+        * @param User $user
+        * @param string $timestamp
+        * @param int $flags
+        * @param Status $status
+        *
+        * @return MutableRevisionRecord
+        */
+       private function makeNewRevision(
+               CommentStoreComment $comment,
+               User $user,
+               $timestamp,
+               $flags,
+               Status $status
+       ) {
+               $wikiPage = $this->getWikiPage();
+               $title = $this->getTitle();
+               $parent = $this->grabParentRevision();
+
+               $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
+               $rev->setPageId( $title->getArticleID() );
+
+               if ( $parent ) {
+                       $oldid = $parent->getId();
+                       $rev->setParentId( $oldid );
+               } else {
+                       $oldid = 0;
+               }
+
+               $rev->setComment( $comment );
+               $rev->setUser( $user );
+               $rev->setTimestamp( $timestamp );
+               $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
+
+               foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
+                       $content = $slot->getContent();
+
+                       // XXX: We may push this up to the "edit controller" level, see T192777.
+                       // TODO: change the signature of PrepareSave to not take a WikiPage!
+                       $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
+
+                       if ( $prepStatus->isOK() ) {
+                               $rev->setSlot( $slot );
+                       }
+
+                       // TODO: MCR: record which problem arose in which slot.
+                       $status->merge( $prepStatus );
+               }
+
+               return $rev;
+       }
+
+       /**
+        * @param CommentStoreComment $summary The edit summary
+        * @param User $user The revision's author
+        * @param int $flags EXIT_XXX constants
+        *
+        * @throws MWException
+        * @return Status
+        */
+       private function doModify( CommentStoreComment $summary, User $user, $flags ) {
+               $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
+
+               // Update article, but only if changed.
+               $status = Status::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
+
+               // Convenience variables
+               $now = $this->getTimestampNow();
+
+               $oldRev = $this->grabParentRevision();
+               $oldid = $oldRev ? $oldRev->getId() : 0;
+
+               if ( !$oldRev ) {
+                       // Article gone missing
+                       $status->fatal( 'edit-gone-missing' );
+
+                       return $status;
+               }
+
+               $newRevisionRecord = $this->makeNewRevision(
+                       $summary,
+                       $user,
+                       $now,
+                       $flags,
+                       $status
+               );
+
+               if ( !$status->isOK() ) {
+                       return $status;
+               }
+
+               // XXX: we may want a flag that allows a null revision to be forced!
+               $changed = $this->derivedDataUpdater->isChange();
+               $mainContent = $newRevisionRecord->getContent( 'main' );
+
+               $dbw = $this->getDBConnectionRef( DB_MASTER );
+
+               if ( $changed ) {
+                       $dbw->startAtomic( __METHOD__ );
+
+                       // Get the latest page_latest value while locking it.
+                       // Do a CAS style check to see if it's the same as when this method
+                       // started. If it changed then bail out before touching the DB.
+                       $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
+                       if ( $latestNow != $oldid ) {
+                               // We don't need to roll back, since we did not modify the database yet.
+                               // XXX: Or do we want to rollback, any transaction started by calling
+                               // code will fail? If we want that, we should probably throw an exception.
+                               $dbw->endAtomic( __METHOD__ );
+                               // Page updated or deleted in the mean time
+                               $status->fatal( 'edit-conflict' );
+
+                               return $status;
+                       }
+
+                       // At this point we are now comitted to returning an OK
+                       // status unless some DB query error or other exception comes up.
+                       // This way callers don't have to call rollback() if $status is bad
+                       // unless they actually try to catch exceptions (which is rare).
+
+                       // Save revision content and meta-data
+                       $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
+                       $newLegacyRevision = new Revision( $newRevisionRecord );
+
+                       // Update page_latest and friends to reflect the new revision
+                       // TODO: move to storage service
+                       $wasRedirect = $this->derivedDataUpdater->wasRedirect();
+                       if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
+                               throw new PageUpdateException( "Failed to update page row to use new revision." );
+                       }
+
+                       // TODO: replace legacy hook!
+                       $tags = $this->computeEffectiveTags( $flags );
+                       Hooks::run(
+                               'NewRevisionFromEditComplete',
+                               [ $wikiPage, $newLegacyRevision, $this->baseRevId, $user, &$tags ]
+                       );
+
+                       // Update recentchanges
+                       if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+                               // Add RC row to the DB
+                               RecentChange::notifyEdit(
+                                       $now,
+                                       $this->getTitle(),
+                                       $newRevisionRecord->isMinor(),
+                                       $user,
+                                       $summary->text, // TODO: pass object when that becomes possible
+                                       $oldid,
+                                       $newRevisionRecord->getTimestamp(),
+                                       ( $flags & EDIT_FORCE_BOT ) > 0,
+                                       '',
+                                       $oldRev->getSize(),
+                                       $newRevisionRecord->getSize(),
+                                       $newRevisionRecord->getId(),
+                                       $this->rcPatrolStatus,
+                                       $tags
+                               );
+                       }
+
+                       $user->incEditCount();
+
+                       $dbw->endAtomic( __METHOD__ );
+               } else {
+                       // T34948: revision ID must be set to page {{REVISIONID}} and
+                       // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
+                       // Since we don't insert a new revision into the database, the least
+                       // error-prone way is to reuse given old revision.
+                       $newRevisionRecord = $oldRev;
+                       $newLegacyRevision = new Revision( $newRevisionRecord );
+               }
+
+               if ( $changed ) {
+                       // Return the new revision to the caller
+                       $status->value['revision-record'] = $newRevisionRecord;
+
+                       // TODO: globally replace usages of 'revision' with getNewRevision()
+                       $status->value['revision'] = $newLegacyRevision;
+               } else {
+                       $status->warning( 'edit-no-change' );
+                       // Update page_touched as updateRevisionOn() was not called.
+                       // Other cache updates are managed in WikiPage::onArticleEdit()
+                       // via WikiPage::doEditUpdates().
+                       $this->getTitle()->invalidateCache( $now );
+               }
+
+               // Do secondary updates once the main changes have been committed...
+               // NOTE: the updates have to be processed before sending the response to the client
+               // (DeferredUpdates::PRESEND), otherwise the client may already be following the
+               // HTTP redirect to the standard view before dervide data has been created - most
+               // importantly, before the parser cache has been updated. This would cause the
+               // content to be parsed a second time, or may cause stale content to be shown.
+               DeferredUpdates::addUpdate(
+                       new AtomicSectionUpdate(
+                               $dbw,
+                               __METHOD__,
+                               function () use (
+                                       $wikiPage, $newRevisionRecord, $newLegacyRevision, $user, $mainContent,
+                                       $summary, $flags, $changed, $status
+                               ) {
+                                       // Update links tables, site stats, etc.
+                                       $this->derivedDataUpdater->prepareUpdate(
+                                               $newRevisionRecord,
+                                               [
+                                                       'changed' => $changed,
+                                               ]
+                                       );
+                                       $this->derivedDataUpdater->doUpdates();
+
+                                       // Trigger post-save hook
+                                       // TODO: replace legacy hook!
+                                       // TODO: avoid pass-by-reference, see T193950
+                                       $params = [ &$wikiPage, &$user, $mainContent, $summary->text, $flags & EDIT_MINOR,
+                                               null, null, &$flags, $newLegacyRevision, &$status, $this->baseRevId,
+                                               $this->undidRevId ];
+                                       Hooks::run( 'PageContentSaveComplete', $params );
+                               }
+                       ),
+                       DeferredUpdates::PRESEND
+               );
+
+               return $status;
+       }
+
+       /**
+        * @param CommentStoreComment $summary The edit summary
+        * @param User $user The revision's author
+        * @param int $flags EXIT_XXX constants
+        *
+        * @throws DBUnexpectedError
+        * @throws MWException
+        * @return Status
+        */
+       private function doCreate( CommentStoreComment $summary, User $user, $flags ) {
+               $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
+
+               if ( !$this->derivedDataUpdater->getSlots()->hasSlot( 'main' ) ) {
+                       throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
+               }
+
+               $status = Status::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
+
+               $now = $this->getTimestampNow();
+
+               $newRevisionRecord = $this->makeNewRevision(
+                       $summary,
+                       $user,
+                       $now,
+                       $flags,
+                       $status
+               );
+
+               if ( !$status->isOK() ) {
+                       return $status;
+               }
+
+               $dbw = $this->getDBConnectionRef( DB_MASTER );
+               $dbw->startAtomic( __METHOD__ );
+
+               // Add the page record unless one already exists for the title
+               // TODO: move to storage service
+               $newid = $wikiPage->insertOn( $dbw );
+               if ( $newid === false ) {
+                       $dbw->endAtomic( __METHOD__ ); // nothing inserted
+                       $status->fatal( 'edit-already-exists' );
+
+                       return $status; // nothing done
+               }
+
+               // At this point we are now comitted to returning an OK
+               // status unless some DB query error or other exception comes up.
+               // This way callers don't have to call rollback() if $status is bad
+               // unless they actually try to catch exceptions (which is rare).
+               $newRevisionRecord->setPageId( $newid );
+
+               // Save the revision text...
+               $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
+               $newLegacyRevision = new Revision( $newRevisionRecord );
+
+               // Update the page record with revision data
+               // TODO: move to storage service
+               if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
+                       throw new PageUpdateException( "Failed to update page row to use new revision." );
+               }
+
+               // TODO: replace legacy hook!
+               $tags = $this->computeEffectiveTags( $flags );
+               Hooks::run(
+                       'NewRevisionFromEditComplete',
+                       [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
+               );
+
+               // Update recentchanges
+               if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+                       // Add RC row to the DB
+                       RecentChange::notifyNew(
+                               $now,
+                               $this->getTitle(),
+                               $newRevisionRecord->isMinor(),
+                               $user,
+                               $summary->text, // TODO: pass object when that becomes possible
+                               ( $flags & EDIT_FORCE_BOT ) > 0,
+                               '',
+                               $newRevisionRecord->getSize(),
+                               $newRevisionRecord->getId(),
+                               $this->rcPatrolStatus,
+                               $tags
+                       );
+               }
+
+               $user->incEditCount();
+
+               if ( $this->usePageCreationLog ) {
+                       // Log the page creation
+                       // @TODO: Do we want a 'recreate' action?
+                       $logEntry = new ManualLogEntry( 'create', 'create' );
+                       $logEntry->setPerformer( $user );
+                       $logEntry->setTarget( $this->getTitle() );
+                       $logEntry->setComment( $summary->text );
+                       $logEntry->setTimestamp( $now );
+                       $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
+                       $logEntry->insert();
+                       // Note that we don't publish page creation events to recentchanges
+                       // (i.e. $logEntry->publish()) since this would create duplicate entries,
+                       // one for the edit and one for the page creation.
+               }
+
+               $dbw->endAtomic( __METHOD__ );
+
+               // Return the new revision to the caller
+               // TODO: globally replace usages of 'revision' with getNewRevision()
+               $status->value['revision'] = $newLegacyRevision;
+               $status->value['revision-record'] = $newRevisionRecord;
+
+               // XXX: make sure we are not loading the Content from the DB
+               $mainContent = $newRevisionRecord->getContent( 'main' );
+
+               // Do secondary updates once the main changes have been committed...
+               DeferredUpdates::addUpdate(
+                       new AtomicSectionUpdate(
+                               $dbw,
+                               __METHOD__,
+                               function () use (
+                                       $wikiPage,
+                                       $newRevisionRecord,
+                                       $newLegacyRevision,
+                                       $user,
+                                       $mainContent,
+                                       $summary,
+                                       $flags,
+                                       $status
+                               ) {
+                                       // Update links, etc.
+                                       $this->derivedDataUpdater->prepareUpdate(
+                                               $newRevisionRecord,
+                                               [ 'created' => true ]
+                                       );
+                                       $this->derivedDataUpdater->doUpdates();
+
+                                       // Trigger post-create hook
+                                       // TODO: replace legacy hook!
+                                       // TODO: avoid pass-by-reference, see T193950
+                                       $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
+                                               $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision ];
+                                       Hooks::run( 'PageContentInsertComplete', $params );
+                                       // Trigger post-save hook
+                                       // TODO: replace legacy hook!
+                                       $params = array_merge( $params, [ &$status, $this->baseRevId, 0 ] );
+                                       Hooks::run( 'PageContentSaveComplete', $params );
+                               }
+                       ),
+                       DeferredUpdates::PRESEND
+               );
+
+               return $status;
+       }
+
+}
index 66ec2c0..7d1b477 100644 (file)
@@ -218,6 +218,48 @@ abstract class RevisionRecord {
                return $this->mSlots->getSlotRoles();
        }
 
+       /**
+        * Returns the slots defined for this revision.
+        *
+        * @return RevisionSlots
+        */
+       public function getSlots() {
+               return $this->mSlots;
+       }
+
+       /**
+        * Returns the slots that originate in this revision.
+        *
+        * Note that this does not include any slots inherited from some earlier revision,
+        * even if they are different from the slots in the immediate parent revision.
+        * This is the case for rollbacks: slots of a rollback revision are inherited from
+        * the rollback target, and are different from the slots in the parent revision,
+        * which was rolled back.
+        *
+        * To find all slots modified by this revision against its immediate parent
+        * revision, use RevisionSlotsUpdate::newFromRevisionSlots().
+        *
+        * @return RevisionSlots
+        */
+       public function getOriginalSlots() {
+               return new RevisionSlots( $this->mSlots->getOriginalSlots() );
+       }
+
+       /**
+        * Returns slots inherited from some previous revision.
+        *
+        * "Inherited" slots are all slots that do not originate in this revision.
+        * Note that these slots may still differ from the one in the parent revision.
+        * This is the case for rollbacks: slots of a rollback revision are inherited from
+        * the rollback target, and are different from the slots in the parent revision,
+        * which was rolled back.
+        *
+        * @return RevisionSlots
+        */
+       public function getInheritedSlots() {
+               return new RevisionSlots( $this->mSlots->getInheritedSlots() );
+       }
+
        /**
         * Get revision ID. Depending on the concrete subclass, this may return null if
         * the revision ID is not known (e.g. because the revision does not yet exist
index c7dcd13..f37e722 100644 (file)
@@ -202,13 +202,14 @@ class RevisionSlots {
        }
 
        /**
-        * Return all slots that are not inherited.
+        * Return all slots that belong to the revision they originate from (that is,
+        * they are not inherited from some other revision).
         *
         * @note This may cause the slot meta-data for the revision to be lazy-loaded.
         *
         * @return SlotRecord[]
         */
-       public function getTouchedSlots() {
+       public function getOriginalSlots() {
                return array_filter(
                        $this->getSlots(),
                        function ( SlotRecord $slot ) {
@@ -218,7 +219,8 @@ class RevisionSlots {
        }
 
        /**
-        * Return all slots that are inherited.
+        * Return all slots that are not not originate in the revision they belong to (that is,
+        * they are inherited from some other revision).
         *
         * @note This may cause the slot meta-data for the revision to be lazy-loaded.
         *
index 0eef90f..d173a3c 100644 (file)
@@ -72,6 +72,39 @@ class RevisionSlotsUpdate {
                return new RevisionSlotsUpdate( $modified, $removed );
        }
 
+       /**
+        * Constructs a RevisionSlotsUpdate representing the update of $parentSlots
+        * when changing $newContent. If a slot has the same content in $newContent
+        * as in $parentSlots, that slot is considered inherited and thus omitted from
+        * the resulting RevisionSlotsUpdate.
+        *
+        * In contrast to newFromRevisionSlots(), slots in $parentSlots that are not present
+        * in $newContent are not considered removed. They are instead assumed to be inherited.
+        *
+        * @param Content[] $newContent The new content, using slot roles as array keys.
+        *
+        * @return RevisionSlotsUpdate
+        */
+       public static function newFromContent( array $newContent, RevisionSlots $parentSlots = null ) {
+               $modified = [];
+
+               foreach ( $newContent as $role => $content ) {
+                       $slot = SlotRecord::newUnsaved( $role, $content );
+
+                       if ( $parentSlots
+                               && $parentSlots->hasSlot( $role )
+                               && $slot->hasSameContent( $parentSlots->getSlot( $role ) )
+                       ) {
+                               // Skip slots that had the same content in the parent revision from $modified.
+                               continue;
+                       }
+
+                       $modified[$role] = $slot;
+               }
+
+               return new RevisionSlotsUpdate( $modified );
+       }
+
        /**
         * @param SlotRecord[] $modifiedSlots
         * @param string[] $removedRoles
@@ -90,6 +123,11 @@ class RevisionSlotsUpdate {
         * Returns a list of modified slot roles, that is, roles modified by calling modifySlot(),
         * and not later removed by calling removeSlot().
         *
+        * Note that slots in modified roles may still be inherited slots. This is for instance
+        * the case when the RevisionSlotsUpdate objects represents some kind of rollback
+        * operation, in which slots that existed in an earlier revision are restored in
+        * a new revision.
+        *
         * @return string[]
         */
        public function getModifiedRoles() {
@@ -239,4 +277,20 @@ class RevisionSlotsUpdate {
                return true;
        }
 
+       /**
+        * Applies this update to the given MutableRevisionSlots, setting all modified slots,
+        * and removing all removed roles.
+        *
+        * @param MutableRevisionSlots $slots
+        */
+       public function apply( MutableRevisionSlots $slots ) {
+               foreach ( $this->getModifiedRoles() as $role ) {
+                       $slots->setSlot( $this->getModifiedSlot( $role ) );
+               }
+
+               foreach ( $this->getRemovedRoles() as $role ) {
+                       $slots->removeSlot( $role );
+               }
+       }
+
 }
index 94dcd07..904090f 100644 (file)
@@ -658,9 +658,9 @@ class RecentChange {
         * Makes an entry in the database corresponding to an edit
         *
         * @param string $timestamp
-        * @param Title &$title
+        * @param Title $title
         * @param bool $minor
-        * @param User &$user
+        * @param User $user
         * @param string $comment
         * @param int $oldId
         * @param string $lastTimestamp
@@ -674,7 +674,7 @@ class RecentChange {
         * @return RecentChange
         */
        public static function notifyEdit(
-               $timestamp, &$title, $minor, &$user, $comment, $oldId, $lastTimestamp,
+               $timestamp, $title, $minor, $user, $comment, $oldId, $lastTimestamp,
                $bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0,
                $tags = []
        ) {
@@ -735,9 +735,9 @@ class RecentChange {
         * Note: the title object must be loaded with the new id using resetArticleID()
         *
         * @param string $timestamp
-        * @param Title &$title
+        * @param Title $title
         * @param bool $minor
-        * @param User &$user
+        * @param User $user
         * @param string $comment
         * @param bool $bot
         * @param string $ip
@@ -748,7 +748,7 @@ class RecentChange {
         * @return RecentChange
         */
        public static function notifyNew(
-               $timestamp, &$title, $minor, &$user, $comment, $bot,
+               $timestamp, $title, $minor, $user, $comment, $bot,
                $ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = []
        ) {
                $rc = new RecentChange;
@@ -805,8 +805,8 @@ class RecentChange {
 
        /**
         * @param string $timestamp
-        * @param Title &$title
-        * @param User &$user
+        * @param Title $title
+        * @param User $user
         * @param string $actionComment
         * @param string $ip
         * @param string $type
@@ -818,7 +818,7 @@ class RecentChange {
         * @param string $actionCommentIRC
         * @return bool
         */
-       public static function notifyLog( $timestamp, &$title, &$user, $actionComment, $ip, $type,
+       public static function notifyLog( $timestamp, $title, $user, $actionComment, $ip, $type,
                $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = ''
        ) {
                global $wgLogRestrictions;
@@ -836,8 +836,8 @@ class RecentChange {
 
        /**
         * @param string $timestamp
-        * @param Title &$title
-        * @param User &$user
+        * @param Title $title
+        * @param User $user
         * @param string $actionComment
         * @param string $ip
         * @param string $type
@@ -851,7 +851,7 @@ class RecentChange {
         * @param bool $isPatrollable Whether this log entry is patrollable
         * @return RecentChange
         */
-       public static function newLogEntry( $timestamp, &$title, &$user, $actionComment, $ip,
+       public static function newLogEntry( $timestamp, $title, $user, $actionComment, $ip,
                $type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '',
                $revId = 0, $isPatrollable = false ) {
                global $wgRequest;
index 910d221..7007316 100644 (file)
@@ -27,6 +27,8 @@ use ParserOutput;
 /**
  * Represents information returned by WikiPage::prepareContentForEdit()
  *
+ * @deprecated since 1.32, use DerivedPageDataUpdater instead.
+ *
  * @since 1.30
  */
 class PreparedEdit {
index e186279..7aa1aad 100644 (file)
 use MediaWiki\Edit\PreparedEdit;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\DerivedPageDataUpdater;
+use MediaWiki\Storage\PageUpdater;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\Storage\RevisionStore;
 use Wikimedia\Assert\Assert;
 use Wikimedia\Rdbms\FakeResultWrapper;
 use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\LoadBalancer;
 
 /**
  * Class representing a MediaWiki article and history.
@@ -88,6 +93,11 @@ class WikiPage implements Page, IDBAccessObject {
         */
        protected $mLinksUpdated = '19700101000000';
 
+       /**
+        * @var DerivedPageDataUpdater|null
+        */
+       private $derivedDataUpdater = null;
+
        /**
         * Constructor and clear the article
         * @param Title $title Reference to a Title object.
@@ -206,6 +216,27 @@ class WikiPage implements Page, IDBAccessObject {
                }
        }
 
+       /**
+        * @return RevisionStore
+        */
+       private function getRevisionStore() {
+               return MediaWikiServices::getInstance()->getRevisionStore();
+       }
+
+       /**
+        * @return ParserCache
+        */
+       private function getParserCache() {
+               return MediaWikiServices::getInstance()->getParserCache();
+       }
+
+       /**
+        * @return LoadBalancer
+        */
+       private function getDBLoadBalancer() {
+               return MediaWikiServices::getInstance()->getDBLoadBalancer();
+       }
+
        /**
         * @todo Move this UI stuff somewhere else
         *
@@ -261,8 +292,8 @@ class WikiPage implements Page, IDBAccessObject {
                $this->mTimestamp = '';
                $this->mIsRedirect = false;
                $this->mLatest = false;
-               // T59026: do not clear mPreparedEdit since prepareTextForEdit() already checks
-               // the requested rev ID and content against the cached one for equality. For most
+               // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already
+               // checks the requested rev ID and content against the cached one. For most
                // content types, the output should not change during the lifetime of this cache.
                // Clearing it can cause extra parses on edit for no reason.
        }
@@ -433,7 +464,7 @@ class WikiPage implements Page, IDBAccessObject {
 
                if ( is_int( $from ) ) {
                        list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
-                       $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
+                       $loadBalancer = $this->getDBLoadBalancer();
                        $db = $loadBalancer->getConnection( $index );
                        $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
 
@@ -456,6 +487,34 @@ class WikiPage implements Page, IDBAccessObject {
                $this->loadFromRow( $data, $from );
        }
 
+       /**
+        * Checks whether the page data was loaded using the given database access mode (or better).
+        *
+        * @since 1.32
+        *
+        * @param string|int $from One of the following:
+        *   - "fromdb" or WikiPage::READ_NORMAL to get from a replica DB.
+        *   - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB.
+        *   - "forupdate"  or WikiPage::READ_LOCKING to get from the master DB
+        *     using SELECT FOR UPDATE.
+        *
+        * @return bool
+        */
+       public function wasLoadedFrom( $from ) {
+               $from = self::convertSelectType( $from );
+
+               if ( !is_int( $from ) ) {
+                       // No idea from where the caller got this data, assume replica DB.
+                       $from = self::READ_NORMAL;
+               }
+
+               if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
+                       return true;
+               }
+
+               return false;
+       }
+
        /**
         * Load the object from a database row
         *
@@ -843,11 +902,14 @@ class WikiPage implements Page, IDBAccessObject {
        public function isCountable( $editInfo = false ) {
                global $wgArticleCountMethod;
 
+               // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable.
+
                if ( !$this->mTitle->isContentPage() ) {
                        return false;
                }
 
                if ( $editInfo ) {
+                       // NOTE: only the main slot can make a page a redirect
                        $content = $editInfo->pstContent;
                } else {
                        $content = $this->getContent();
@@ -1031,7 +1093,7 @@ class WikiPage implements Page, IDBAccessObject {
         * @return UserArrayFromResult
         */
        public function getContributors() {
-               // @todo FIXME: This is expensive; cache this info somewhere.
+               // @todo: This is expensive; cache this info somewhere.
 
                $dbr = wfGetDB( DB_REPLICA );
 
@@ -1119,7 +1181,7 @@ class WikiPage implements Page, IDBAccessObject {
                }
 
                if ( $useParserCache ) {
-                       $parserOutput = MediaWikiServices::getInstance()->getParserCache()
+                       $parserOutput = $this->getParserCache()
                                ->get( $this, $parserOptions );
                        if ( $parserOutput !== false ) {
                                return $parserOutput;
@@ -1197,6 +1259,8 @@ class WikiPage implements Page, IDBAccessObject {
         * or else the record will be left in a funky state.
         * Best if all done inside a transaction.
         *
+        * @todo Factor out into a PageStore service, to be used by PageUpdater.
+        *
         * @param IDatabase $dbw
         * @param int|null $pageId Custom page ID that will be used for the insert statement
         *
@@ -1237,6 +1301,8 @@ class WikiPage implements Page, IDBAccessObject {
        /**
         * Update the page record to point to a newly saved revision.
         *
+        * @todo Factor out into a PageStore service, or move into PageUpdater.
+        *
         * @param IDatabase $dbw
         * @param Revision $revision For ID number, and text used to set
         *   length and redirect status fields
@@ -1252,6 +1318,10 @@ class WikiPage implements Page, IDBAccessObject {
        ) {
                global $wgContentHandlerUseDB;
 
+               // TODO: move into PageUpdater or PageStore
+               // NOTE: when doing that, make sure cached fields get reset in doEditContent,
+               // and in the compat stub!
+
                // Assertion to try to catch T92046
                if ( (int)$revision->getId() === 0 ) {
                        throw new InvalidArgumentException(
@@ -1430,7 +1500,7 @@ class WikiPage implements Page, IDBAccessObject {
        ) {
                $baseRevId = null;
                if ( $edittime && $sectionId !== 'new' ) {
-                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+                       $lb = $this->getDBLoadBalancer();
                        $dbr = $lb->getConnection( DB_REPLICA );
                        $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
                        // Try the master if this thread may have just added it.
@@ -1503,6 +1573,10 @@ class WikiPage implements Page, IDBAccessObject {
 
        /**
         * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
+        *
+        * @deprecated since 1.32, use exists() instead, or simply omit the EDIT_UPDATE
+        * and EDIT_NEW flags. To protect against race conditions, use PageUpdater::grabParentRevision.
+        *
         * @param int $flags
         * @return int Updated $flags
         */
@@ -1518,12 +1592,132 @@ class WikiPage implements Page, IDBAccessObject {
                return $flags;
        }
 
+       /**
+        * @return DerivedPageDataUpdater
+        */
+       private function newDerivedDataUpdater() {
+               global $wgContLang, $wgRCWatchCategoryMembership, $wgArticleCountMethod;
+
+               $derivedDataUpdater = new DerivedPageDataUpdater(
+                       $this, // NOTE: eventually, PageUpdater should not know about WikiPage
+                       $this->getRevisionStore(),
+                       $this->getParserCache(),
+                       JobQueueGroup::singleton(),
+                       MessageCache::singleton(),
+                       $wgContLang,
+                       LoggerFactory::getInstance( 'SaveParse' )
+               );
+
+               $derivedDataUpdater->setRcWatchCategoryMembership( $wgRCWatchCategoryMembership );
+               $derivedDataUpdater->setArticleCountMethod( $wgArticleCountMethod );
+
+               return $derivedDataUpdater;
+       }
+
+       /**
+        * Returns a DerivedPageDataUpdater for use with the given target revision or new content.
+        * This method attempts to re-use the same DerivedPageDataUpdater instance for subsequent calls.
+        * The parameters passed to this method are used to ensure that the DerivedPageDataUpdater
+        * returned matches that caller's expectations, allowing an existing instance to be re-used
+        * if the given parameters match that instance's internal state according to
+        * DerivedPageDataUpdater::isReusableFor(), and creating a new instance of the parameters do not
+        * match the existign one.
+        *
+        * If neither $forRevision nor $forUpdate is given, a new DerivedPageDataUpdater is always
+        * created, replacing any DerivedPageDataUpdater currently cached.
+        *
+        * MCR migration note: this replaces WikiPage::prepareContentForEdit.
+        *
+        * @since 1.32
+        *
+        * @param User|null $forUser The user that will be used for, or was used for, PST.
+        * @param RevisionRecord|null $forRevision The revision created by the edit for which
+        *        to perform updates, if the edit was already saved.
+        * @param RevisionSlotsUpdate|null $forUpdate The new content to be saved by the edit (pre PST),
+        *        if the edit was not yet saved.
+        *
+        * @return DerivedPageDataUpdater
+        */
+       private function getDerivedDataUpdater(
+               User $forUser = null,
+               RevisionRecord $forRevision = null,
+               RevisionSlotsUpdate $forUpdate = null
+       ) {
+               if ( !$forRevision && !$forUpdate ) {
+                       // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is
+                       // going to use it with.
+                       $this->derivedDataUpdater = null;
+               }
+
+               if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) {
+                       // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference
+                       // to it did not yet initialize it, because we don't know what data it will be
+                       // initialized with.
+                       $this->derivedDataUpdater = null;
+               }
+
+               // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance.
+               // However, there is no good way to construct a cache key. We'd need to check against all
+               // cached instances.
+
+               if ( $this->derivedDataUpdater
+                       && !$this->derivedDataUpdater->isReusableFor(
+                               $forUser,
+                               $forRevision,
+                               $forUpdate
+                       )
+               ) {
+                       $this->derivedDataUpdater = null;
+               }
+
+               if ( !$this->derivedDataUpdater ) {
+                       $this->derivedDataUpdater = $this->newDerivedDataUpdater();
+               }
+
+               return $this->derivedDataUpdater;
+       }
+
+       /**
+        * Returns a PageUpdater for creating new revisions on this page (or creating the page).
+        *
+        * The PageUpdater can also be used to detect the need for edit conflict resolution,
+        * and to protected such conflict resolution from concurrent edits using a check-and-set
+        * mechanism.
+        *
+        * @since 1.32
+        *
+        * @param User $user
+        *
+        * @return PageUpdater
+        */
+       public function newPageUpdater( User $user ) {
+               global $wgAjaxEditStash, $wgUseAutomaticEditSummaries, $wgPageCreationLog;
+
+               $pageUpdater = new PageUpdater(
+                       $user,
+                       $this, // NOTE: eventually, PageUpdater should not know about WikiPage
+                       $this->getDerivedDataUpdater( $user ),
+                       $this->getDBLoadBalancer(),
+                       $this->getRevisionStore()
+               );
+
+               $pageUpdater->setUsePageCreationLog( $wgPageCreationLog );
+               $pageUpdater->setAjaxEditStash( $wgAjaxEditStash );
+               $pageUpdater->setUseAutomaticEditSummaries( $wgUseAutomaticEditSummaries );
+
+               return $pageUpdater;
+       }
+
        /**
         * Change an existing article or create a new article. Updates RC and all necessary caches,
         * optionally via the deferred update array.
         *
+        * @deprecated since 1.32, use PageUpdater::saveRevision instead. Note that the new method
+        * expects callers to take care of checking EDIT_MINOR against the minoredit right, and to
+        * apply the autopatrol right as appropriate.
+        *
         * @param Content $content New content
-        * @param string $summary Edit summary
+        * @param string|CommentStoreComment $summary Edit summary
         * @param int $flags Bitfield:
         *      EDIT_NEW
         *          Article is known or assumed to be non-existent, create a new one
@@ -1551,8 +1745,7 @@ class WikiPage implements Page, IDBAccessObject {
         *   This is not the parent revision ID, rather the revision ID for older
         *   content used as the source for a rollback, for example.
         * @param User $user The user doing the edit
-        * @param string $serialFormat Format for storing the content in the
-        *   database.
+        * @param string $serialFormat IGNORED.
         * @param array|null $tags Change tags to apply to this edit
         * Callers are responsible for permission checks
         * (with ChangeTags::canAddTagsAccompanyingChange)
@@ -1580,422 +1773,58 @@ class WikiPage implements Page, IDBAccessObject {
                Content $content, $summary, $flags = 0, $baseRevId = false,
                User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0
        ) {
-               global $wgUser, $wgUseAutomaticEditSummaries;
-
-               // Old default parameter for $tags was null
-               if ( $tags === null ) {
-                       $tags = [];
-               }
-
-               // Low-level sanity check
-               if ( $this->mTitle->getText() === '' ) {
-                       throw new MWException( 'Something is trying to edit an article with an empty title' );
-               }
-               // Make sure the given content type is allowed for this page
-               if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
-                       return Status::newFatal( 'content-not-allowed-here',
-                               ContentHandler::getLocalizedName( $content->getModel() ),
-                               $this->mTitle->getPrefixedText()
-                       );
-               }
-
-               // Load the data from the master database if needed.
-               // The caller may already loaded it from the master or even loaded it using
-               // SELECT FOR UPDATE, so do not override that using clear().
-               $this->loadPageData( 'fromdbmaster' );
-
-               $user = $user ?: $wgUser;
-               $flags = $this->checkFlags( $flags );
-
-               // Avoid PHP 7.1 warning of passing $this by reference
-               $wikiPage = $this;
-
-               // Trigger pre-save hook (using provided edit summary)
-               $hookStatus = Status::newGood( [] );
-               $hook_args = [ &$wikiPage, &$user, &$content, &$summary,
-                                                       $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
-               // Check if the hook rejected the attempted save
-               if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
-                       if ( $hookStatus->isOK() ) {
-                               // Hook returned false but didn't call fatal(); use generic message
-                               $hookStatus->fatal( 'edit-hook-aborted' );
-                       }
+               global $wgUser, $wgUseNPPatrol, $wgUseRCPatrol;
 
-                       return $hookStatus;
+               if ( !( $summary instanceof CommentStoreComment ) ) {
+                       $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
                }
 
-               $old_revision = $this->getRevision(); // current revision
-               $old_content = $this->getContent( Revision::RAW ); // current revision's content
-
-               $handler = $content->getContentHandler();
-               $tag = $handler->getChangeTag( $old_content, $content, $flags );
-               // If there is no applicable tag, null is returned, so we need to check
-               if ( $tag ) {
-                       $tags[] = $tag;
-               }
-
-               // Check for undo tag
-               if ( $undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
-                       $tags[] = 'mw-undo';
-               }
-
-               // Provide autosummaries if summary is not provided and autosummaries are enabled
-               if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
-                       $summary = $handler->getAutosummary( $old_content, $content, $flags );
-               }
-
-               // Avoid statsd noise and wasted cycles check the edit stash (T136678)
-               if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
-                       $useCache = false;
-               } else {
-                       $useCache = true;
-               }
-
-               // Get the pre-save transform content and final parser output
-               $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
-               $pstContent = $editInfo->pstContent; // Content object
-               $meta = [
-                       'bot' => ( $flags & EDIT_FORCE_BOT ),
-                       'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
-                       'serialized' => $pstContent->serialize( $serialFormat ),
-                       'serialFormat' => $serialFormat,
-                       'baseRevId' => $baseRevId,
-                       'oldRevision' => $old_revision,
-                       'oldContent' => $old_content,
-                       'oldId' => $this->getLatest(),
-                       'oldIsRedirect' => $this->isRedirect(),
-                       'oldCountable' => $this->isCountable(),
-                       'tags' => ( $tags !== null ) ? (array)$tags : [],
-                       'undidRevId' => $undidRevId
-               ];
-
-               // Actually create the revision and create/update the page
-               if ( $flags & EDIT_UPDATE ) {
-                       $status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
-               } else {
-                       $status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
+               if ( !$user ) {
+                       $user = $wgUser;
                }
 
-               // Promote user to any groups they meet the criteria for
-               DeferredUpdates::addCallableUpdate( function () use ( $user ) {
-                       $user->addAutopromoteOnceGroups( 'onEdit' );
-                       $user->addAutopromoteOnceGroups( 'onView' ); // b/c
-               } );
-
-               return $status;
-       }
-
-       /**
-        * @param Content $content Pre-save transform content
-        * @param int $flags
-        * @param User $user
-        * @param string $summary
-        * @param array $meta
-        * @return Status
-        * @throws DBUnexpectedError
-        * @throws Exception
-        * @throws FatalError
-        * @throws MWException
-        */
-       private function doModify(
-               Content $content, $flags, User $user, $summary, array $meta
-       ) {
-               global $wgUseRCPatrol;
-
-               // Update article, but only if changed.
-               $status = Status::newGood( [ 'new' => false, 'revision' => null ] );
-
-               // Convenience variables
-               $now = wfTimestampNow();
-               $oldid = $meta['oldId'];
-               /** @var Content|null $oldContent */
-               $oldContent = $meta['oldContent'];
-               $newsize = $content->getSize();
-
-               if ( !$oldid ) {
-                       // Article gone missing
-                       $status->fatal( 'edit-gone-missing' );
-
-                       return $status;
-               } elseif ( !$oldContent ) {
-                       // Sanity check for T39225
-                       throw new MWException( "Could not find text for current revision {$oldid}." );
+               // TODO: this check is here for backwards-compatibility with 1.31 behavior.
+               // Checking the minoredit right should be done in the same place the 'bot' right is
+               // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave.
+               if ( ( $flags & EDIT_MINOR ) && !$user->isAllowed( 'minoredit' ) ) {
+                       $flags = ( $flags & ~EDIT_MINOR );
                }
 
-               $changed = !$content->equals( $oldContent );
-
-               $dbw = wfGetDB( DB_MASTER );
-
-               if ( $changed ) {
-                       // @TODO: pass content object?!
-                       $revision = new Revision( [
-                               'page'       => $this->getId(),
-                               'title'      => $this->mTitle, // for determining the default content model
-                               'comment'    => $summary,
-                               'minor_edit' => $meta['minor'],
-                               'text'       => $meta['serialized'],
-                               'len'        => $newsize,
-                               'parent_id'  => $oldid,
-                               'user'       => $user->getId(),
-                               'user_text'  => $user->getName(),
-                               'timestamp'  => $now,
-                               'content_model' => $content->getModel(),
-                               'content_format' => $meta['serialFormat'],
-                       ] );
-
-                       $prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
-                       $status->merge( $prepStatus );
-                       if ( !$status->isOK() ) {
-                               return $status;
-                       }
-
-                       $dbw->startAtomic( __METHOD__ );
-                       // Get the latest page_latest value while locking it.
-                       // Do a CAS style check to see if it's the same as when this method
-                       // started. If it changed then bail out before touching the DB.
-                       $latestNow = $this->lockAndGetLatest();
-                       if ( $latestNow != $oldid ) {
-                               $dbw->endAtomic( __METHOD__ );
-                               // Page updated or deleted in the mean time
-                               $status->fatal( 'edit-conflict' );
-
-                               return $status;
-                       }
-
-                       // At this point we are now comitted to returning an OK
-                       // status unless some DB query error or other exception comes up.
-                       // This way callers don't have to call rollback() if $status is bad
-                       // unless they actually try to catch exceptions (which is rare).
+               // NOTE: while doEditContent() executes, callbacks to getDerivedDataUpdater and
+               // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also
+               // used by this PageUpdater. However, there is no guarantee for this.
+               $updater = $this->newPageUpdater( $user );
+               $updater->setContent( 'main', $content );
+               $updater->setBaseRevisionId( $baseRevId );
+               $updater->setUndidRevisionId( $undidRevId );
 
-                       // Save the revision text
-                       $revisionId = $revision->insertOn( $dbw );
-                       // Update page_latest and friends to reflect the new revision
-                       if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
-                               throw new MWException( "Failed to update page row to use new revision." );
-                       }
+               $needsPatrol = $wgUseRCPatrol || ( $wgUseNPPatrol && !$this->exists() );
 
-                       $tags = $meta['tags'];
-                       Hooks::run( 'NewRevisionFromEditComplete',
-                               [ $this, $revision, $meta['baseRevId'], $user, &$tags ] );
-
-                       // Update recentchanges
-                       if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
-                               // Mark as patrolled if the user can do so
-                               $autopatrolled = $wgUseRCPatrol && !count(
-                                               $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
-                               // Add RC row to the DB
-                               RecentChange::notifyEdit(
-                                       $now,
-                                       $this->mTitle,
-                                       $revision->isMinor(),
-                                       $user,
-                                       $summary,
-                                       $oldid,
-                                       $this->getTimestamp(),
-                                       $meta['bot'],
-                                       '',
-                                       $oldContent ? $oldContent->getSize() : 0,
-                                       $newsize,
-                                       $revisionId,
-                                       $autopatrolled ? RecentChange::PRC_AUTOPATROLLED :
-                                               RecentChange::PRC_UNPATROLLED,
-                                       $tags
-                               );
-                       }
-
-                       $user->incEditCount();
-
-                       $dbw->endAtomic( __METHOD__ );
-                       $this->mTimestamp = $now;
-               } else {
-                       // T34948: revision ID must be set to page {{REVISIONID}} and
-                       // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
-                       // Since we don't insert a new revision into the database, the least
-                       // error-prone way is to reuse given old revision.
-                       $revision = $meta['oldRevision'];
+               // TODO: this logic should not be in the storage layer, it's here for compatibility
+               // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
+               // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
+               if ( $needsPatrol && $this->getTitle()->userCan( 'autopatrol', $user ) ) {
+                       $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
                }
 
-               if ( $changed ) {
-                       // Return the new revision to the caller
-                       $status->value['revision'] = $revision;
-               } else {
-                       $status->warning( 'edit-no-change' );
-                       // Update page_touched as updateRevisionOn() was not called.
-                       // Other cache updates are managed in onArticleEdit() via doEditUpdates().
-                       $this->mTitle->invalidateCache( $now );
-               }
+               $updater->addTags( $tags );
 
-               // Do secondary updates once the main changes have been committed...
-               DeferredUpdates::addUpdate(
-                       new AtomicSectionUpdate(
-                               $dbw,
-                               __METHOD__,
-                               function () use (
-                                       $revision, &$user, $content, $summary, &$flags,
-                                       $changed, $meta, &$status
-                               ) {
-                                       // Update links tables, site stats, etc.
-                                       $this->doEditUpdates(
-                                               $revision,
-                                               $user,
-                                               [
-                                                       'changed' => $changed,
-                                                       'oldcountable' => $meta['oldCountable'],
-                                                       'oldrevision' => $meta['oldRevision']
-                                               ]
-                                       );
-                                       // Avoid PHP 7.1 warning of passing $this by reference
-                                       $wikiPage = $this;
-                                       // Trigger post-save hook
-                                       $params = [ &$wikiPage, &$user, $content, $summary, $flags & EDIT_MINOR,
-                                               null, null, &$flags, $revision, &$status, $meta['baseRevId'],
-                                               $meta['undidRevId'] ];
-                                       Hooks::run( 'PageContentSaveComplete', $params );
-                               }
-                       ),
-                       DeferredUpdates::PRESEND
+               $revRec = $updater->saveRevision(
+                       $summary,
+                       $flags
                );
 
-               return $status;
-       }
-
-       /**
-        * @param Content $content Pre-save transform content
-        * @param int $flags
-        * @param User $user
-        * @param string $summary
-        * @param array $meta
-        * @return Status
-        * @throws DBUnexpectedError
-        * @throws Exception
-        * @throws FatalError
-        * @throws MWException
-        */
-       private function doCreate(
-               Content $content, $flags, User $user, $summary, array $meta
-       ) {
-               global $wgUseRCPatrol, $wgUseNPPatrol, $wgPageCreationLog;
-
-               $status = Status::newGood( [ 'new' => true, 'revision' => null ] );
-
-               $now = wfTimestampNow();
-               $newsize = $content->getSize();
-               $prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
-               $status->merge( $prepStatus );
-               if ( !$status->isOK() ) {
-                       return $status;
-               }
-
-               $dbw = wfGetDB( DB_MASTER );
-               $dbw->startAtomic( __METHOD__ );
-
-               // Add the page record unless one already exists for the title
-               $newid = $this->insertOn( $dbw );
-               if ( $newid === false ) {
-                       $dbw->endAtomic( __METHOD__ ); // nothing inserted
-                       $status->fatal( 'edit-already-exists' );
-
-                       return $status; // nothing done
-               }
-
-               // At this point we are now comitted to returning an OK
-               // status unless some DB query error or other exception comes up.
-               // This way callers don't have to call rollback() if $status is bad
-               // unless they actually try to catch exceptions (which is rare).
-
-               // @TODO: pass content object?!
-               $revision = new Revision( [
-                       'page'       => $newid,
-                       'title'      => $this->mTitle, // for determining the default content model
-                       'comment'    => $summary,
-                       'minor_edit' => $meta['minor'],
-                       'text'       => $meta['serialized'],
-                       'len'        => $newsize,
-                       'user'       => $user->getId(),
-                       'user_text'  => $user->getName(),
-                       'timestamp'  => $now,
-                       'content_model' => $content->getModel(),
-                       'content_format' => $meta['serialFormat'],
-               ] );
-
-               // Save the revision text...
-               $revisionId = $revision->insertOn( $dbw );
-               // Update the page record with revision data
-               if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
-                       throw new MWException( "Failed to update page row to use new revision." );
-               }
-
-               Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
-
-               // Update recentchanges
-               if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
-                       // Mark as patrolled if the user can do so
-                       $patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
-                               !count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
-                       // Add RC row to the DB
-                       RecentChange::notifyNew(
-                               $now,
-                               $this->mTitle,
-                               $revision->isMinor(),
-                               $user,
-                               $summary,
-                               $meta['bot'],
-                               '',
-                               $newsize,
-                               $revisionId,
-                               $patrolled,
-                               $meta['tags']
-                       );
-               }
-
-               $user->incEditCount();
-
-               if ( $wgPageCreationLog ) {
-                       // Log the page creation
-                       // @TODO: Do we want a 'recreate' action?
-                       $logEntry = new ManualLogEntry( 'create', 'create' );
-                       $logEntry->setPerformer( $user );
-                       $logEntry->setTarget( $this->mTitle );
-                       $logEntry->setComment( $summary );
-                       $logEntry->setTimestamp( $now );
-                       $logEntry->setAssociatedRevId( $revisionId );
-                       $logid = $logEntry->insert();
-                       // Note that we don't publish page creation events to recentchanges
-                       // (i.e. $logEntry->publish()) since this would create duplicate entries,
-                       // one for the edit and one for the page creation.
+               // $revRec will be null if the edit failed, or if no new revision was created because
+               // the content did not change.
+               if ( $revRec ) {
+                       // update cached fields
+                       // TODO: this is currently redundant to what is done in updateRevisionOn.
+                       // But updateRevisionOn() should move into PageStore, and then this will be needed.
+                       $this->setLastEdit( new Revision( $revRec ) ); // TODO: use RevisionRecord
+                       $this->mLatest = $revRec->getId();
                }
 
-               $dbw->endAtomic( __METHOD__ );
-               $this->mTimestamp = $now;
-
-               // Return the new revision to the caller
-               $status->value['revision'] = $revision;
-
-               // Do secondary updates once the main changes have been committed...
-               DeferredUpdates::addUpdate(
-                       new AtomicSectionUpdate(
-                               $dbw,
-                               __METHOD__,
-                               function () use (
-                                       $revision, &$user, $content, $summary, &$flags, $meta, &$status
-                               ) {
-                                       // Update links, etc.
-                                       $this->doEditUpdates( $revision, $user, [ 'created' => true ] );
-                                       // Avoid PHP 7.1 warning of passing $this by reference
-                                       $wikiPage = $this;
-                                       // Trigger post-create hook
-                                       $params = [ &$wikiPage, &$user, $content, $summary,
-                                                               $flags & EDIT_MINOR, null, null, &$flags, $revision ];
-                                       Hooks::run( 'PageContentInsertComplete', $params );
-                                       // Trigger post-save hook
-                                       $params = array_merge( $params, [ &$status, $meta['baseRevId'], 0 ] );
-                                       Hooks::run( 'PageContentSaveComplete', $params );
-                               }
-                       ),
-                       DeferredUpdates::PRESEND
-               );
-
-               return $status;
+               return $updater->getStatus();
        }
 
        /**
@@ -2027,14 +1856,17 @@ class WikiPage implements Page, IDBAccessObject {
        /**
         * Prepare content which is about to be saved.
         *
-        * Prior to 1.30, this returned a stdClass object with the same class
-        * members.
+        * Prior to 1.30, this returned a stdClass.
+        *
+        * @deprecated since 1.32, use getDerivedDataUpdater instead.
         *
         * @param Content $content
-        * @param Revision|int|null $revision Revision object. For backwards compatibility, a
-        *        revision ID is also accepted, but this is deprecated.
+        * @param Revision|RevisionRecord|int|null $revision Revision object.
+        *        For backwards compatibility, a revision ID is also accepted,
+        *        but this is deprecated.
+        *        Used with vary-revision or vary-revision-id.
         * @param User|null $user
-        * @param string|null $serialFormat
+        * @param string|null $serialFormat IGNORED
         * @param bool $useCache Check shared prepared edit cache
         *
         * @return PreparedEdit
@@ -2042,125 +1874,45 @@ class WikiPage implements Page, IDBAccessObject {
         * @since 1.21
         */
        public function prepareContentForEdit(
-               Content $content, $revision = null, User $user = null,
-               $serialFormat = null, $useCache = true
+               Content $content,
+               $revision = null,
+               User $user = null,
+               $serialFormat = null,
+               $useCache = true
        ) {
-               global $wgContLang, $wgUser, $wgAjaxEditStash;
+               global $wgUser;
 
-               if ( is_object( $revision ) ) {
-                       $revid = $revision->getId();
-               } else {
+               if ( !$user ) {
+                       $user = $wgUser;
+               }
+
+               if ( !is_object( $revision ) ) {
                        $revid = $revision;
                        // This code path is deprecated, and nothing is known to
                        // use it, so performance here shouldn't be a worry.
                        if ( $revid !== null ) {
                                wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' );
-                               $revision = Revision::newFromId( $revid, Revision::READ_LATEST );
+                               $store = $this->getRevisionStore();
+                               $revision = $store->getRevisionById( $revid, Revision::READ_LATEST );
                        } else {
                                $revision = null;
                        }
+               } elseif ( $revision instanceof Revision ) {
+                       $revision = $revision->getRevisionRecord();
                }
 
-               $user = is_null( $user ) ? $wgUser : $user;
-               // XXX: check $user->getId() here???
-
-               // Use a sane default for $serialFormat, see T59026
-               if ( $serialFormat === null ) {
-                       $serialFormat = $content->getContentHandler()->getDefaultFormat();
-               }
-
-               if ( $this->mPreparedEdit
-                       && isset( $this->mPreparedEdit->newContent )
-                       && $this->mPreparedEdit->newContent->equals( $content )
-                       && $this->mPreparedEdit->revid == $revid
-                       && $this->mPreparedEdit->format == $serialFormat
-                       // XXX: also check $user here?
-               ) {
-                       // Already prepared
-                       return $this->mPreparedEdit;
-               }
-
-               // The edit may have already been prepared via api.php?action=stashedit
-               $cachedEdit = $useCache && $wgAjaxEditStash
-                       ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
-                       : false;
-
-               $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
-               Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
+               $slots = RevisionSlotsUpdate::newFromContent( [ 'main' => $content ] );
+               $updater = $this->getDerivedDataUpdater( $user, $revision, $slots );
 
-               $edit = new PreparedEdit();
-               if ( $cachedEdit ) {
-                       $edit->timestamp = $cachedEdit->timestamp;
-               } else {
-                       $edit->timestamp = wfTimestampNow();
-               }
-               // @note: $cachedEdit is safely not used if the rev ID was referenced in the text
-               $edit->revid = $revid;
+               if ( !$updater->isUpdatePrepared() ) {
+                       $updater->prepareContent( $user, $slots, [], $useCache );
 
-               if ( $cachedEdit ) {
-                       $edit->pstContent = $cachedEdit->pstContent;
-               } else {
-                       $edit->pstContent = $content
-                               ? $content->preSaveTransform( $this->mTitle, $user, $popts )
-                               : null;
-               }
-
-               $edit->format = $serialFormat;
-               $edit->popts = $this->makeParserOptions( 'canonical' );
-               if ( $cachedEdit ) {
-                       $edit->output = $cachedEdit->output;
-               } else {
                        if ( $revision ) {
-                               // We get here if vary-revision is set. This means that this page references
-                               // itself (such as via self-transclusion). In this case, we need to make sure
-                               // that any such self-references refer to the newly-saved revision, and not
-                               // to the previous one, which could otherwise happen due to replica DB lag.
-                               $oldCallback = $edit->popts->getCurrentRevisionCallback();
-                               $edit->popts->setCurrentRevisionCallback(
-                                       function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
-                                               if ( $title->equals( $revision->getTitle() ) ) {
-                                                       return $revision;
-                                               } else {
-                                                       return call_user_func( $oldCallback, $title, $parser );
-                                               }
-                                       }
-                               );
-                       } else {
-                               // Try to avoid a second parse if {{REVISIONID}} is used
-                               $dbIndex = ( $this->mDataLoadedFrom & self::READ_LATEST ) === self::READ_LATEST
-                                       ? DB_MASTER // use the best possible guess
-                                       : DB_REPLICA; // T154554
-
-                               $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
-                                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-                                       // Use a fresh connection in order to see the latest data, by avoiding
-                                       // stale data from REPEATABLE-READ snapshots.
-                                       $db = $lb->getConnectionRef( $dbIndex, [], false, $lb::CONN_TRX_AUTO );
-
-                                       return 1 + (int)$db->selectField(
-                                               'revision',
-                                               'MAX(rev_id)',
-                                               [],
-                                               __METHOD__
-                                       );
-                               } );
+                               $updater->prepareUpdate( $revision );
                        }
-                       $edit->output = $edit->pstContent
-                               ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
-                               : null;
-               }
-
-               $edit->newContent = $content;
-               $edit->oldContent = $this->getContent( Revision::RAW );
-
-               if ( $edit->output ) {
-                       $edit->output->setCacheTime( wfTimestampNow() );
                }
 
-               // Process cache the result
-               $this->mPreparedEdit = $edit;
-
-               return $edit;
+               return $updater->getPreparedEdit();
        }
 
        /**
@@ -2169,6 +1921,8 @@ class WikiPage implements Page, IDBAccessObject {
         * Purges pages that include this page if the text was changed here.
         * Every 100th edit, prune the recent changes table.
         *
+        * @deprecated since 1.32, use PageUpdater::doEditUpdates instead.
+        *
         * @param Revision $revision
         * @param User $user User object that did the revision
         * @param array $options Array of options, following indexes are used:
@@ -2185,165 +1939,13 @@ class WikiPage implements Page, IDBAccessObject {
         *   - 'no-change': don't update the article count, ever
         */
        public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
-               global $wgRCWatchCategoryMembership;
-
-               $options += [
-                       'changed' => true,
-                       'created' => false,
-                       'moved' => false,
-                       'restored' => false,
-                       'oldrevision' => null,
-                       'oldcountable' => null
-               ];
-               $content = $revision->getContent();
-
-               $logger = LoggerFactory::getInstance( 'SaveParse' );
-
-               // See if the parser output before $revision was inserted is still valid
-               $editInfo = false;
-               if ( !$this->mPreparedEdit ) {
-                       $logger->debug( __METHOD__ . ": No prepared edit...\n" );
-               } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
-                       $logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
-               } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
-                       && $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
-               ) {
-                       $logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
-               } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
-                       $logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
-               } else {
-                       wfDebug( __METHOD__ . ": Using prepared edit...\n" );
-                       $editInfo = $this->mPreparedEdit;
-               }
-
-               if ( !$editInfo ) {
-                       // Parse the text again if needed. Be careful not to do pre-save transform twice:
-                       // $text is usually already pre-save transformed once. Avoid using the edit stash
-                       // as any prepared content from there or in doEditContent() was already rejected.
-                       $editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false );
-               }
-
-               // Save it to the parser cache.
-               // Make sure the cache time matches page_touched to avoid double parsing.
-               MediaWikiServices::getInstance()->getParserCache()->save(
-                       $editInfo->output, $this, $editInfo->popts,
-                       $revision->getTimestamp(), $editInfo->revid
-               );
-
-               // Update the links tables and other secondary data
-               if ( $content ) {
-                       $recursive = $options['changed']; // T52785
-                       $updates = $content->getSecondaryDataUpdates(
-                               $this->getTitle(), null, $recursive, $editInfo->output
-                       );
-                       foreach ( $updates as $update ) {
-                               $update->setCause( 'edit-page', $user->getName() );
-                               if ( $update instanceof LinksUpdate ) {
-                                       $update->setRevision( $revision );
-                                       $update->setTriggeringUser( $user );
-                               }
-                               DeferredUpdates::addUpdate( $update );
-                       }
-                       if ( $wgRCWatchCategoryMembership
-                               && $this->getContentHandler()->supportsCategories() === true
-                               && ( $options['changed'] || $options['created'] )
-                               && !$options['restored']
-                       ) {
-                               // Note: jobs are pushed after deferred updates, so the job should be able to see
-                               // the recent change entry (also done via deferred updates) and carry over any
-                               // bot/deletion/IP flags, ect.
-                               JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
-                                       $this->getTitle(),
-                                       [
-                                               'pageId' => $this->getId(),
-                                               'revTimestamp' => $revision->getTimestamp()
-                                       ]
-                               ) );
-                       }
-               }
-
-               // Avoid PHP 7.1 warning of passing $this by reference
-               $wikiPage = $this;
-
-               Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $options['changed'] ] );
-
-               if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
-                       // Flush old entries from the `recentchanges` table
-                       if ( mt_rand( 0, 9 ) == 0 ) {
-                               JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
-                       }
-               }
-
-               if ( !$this->exists() ) {
-                       return;
-               }
-
-               $id = $this->getId();
-               $title = $this->mTitle->getPrefixedDBkey();
-               $shortTitle = $this->mTitle->getDBkey();
-
-               if ( $options['oldcountable'] === 'no-change' ||
-                       ( !$options['changed'] && !$options['moved'] )
-               ) {
-                       $good = 0;
-               } elseif ( $options['created'] ) {
-                       $good = (int)$this->isCountable( $editInfo );
-               } elseif ( $options['oldcountable'] !== null ) {
-                       $good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable'];
-               } else {
-                       $good = 0;
-               }
-               $edits = $options['changed'] ? 1 : 0;
-               $pages = $options['created'] ? 1 : 0;
-
-               DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
-                       [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
-               ) );
-               DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) );
-
-               // If this is another user's talk page, update newtalk.
-               // Don't do this if $options['changed'] = false (null-edits) nor if
-               // it's a minor edit and the user doesn't want notifications for those.
-               if ( $options['changed']
-                       && $this->mTitle->getNamespace() == NS_USER_TALK
-                       && $shortTitle != $user->getTitleKey()
-                       && !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) )
-               ) {
-                       $recipient = User::newFromName( $shortTitle, false );
-                       if ( !$recipient ) {
-                               wfDebug( __METHOD__ . ": invalid username\n" );
-                       } else {
-                               // Avoid PHP 7.1 warning of passing $this by reference
-                               $wikiPage = $this;
-
-                               // Allow extensions to prevent user notification
-                               // when a new message is added to their talk page
-                               if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
-                                       if ( User::isIP( $shortTitle ) ) {
-                                               // An anonymous user
-                                               $recipient->setNewtalk( true, $revision );
-                                       } elseif ( $recipient->isLoggedIn() ) {
-                                               $recipient->setNewtalk( true, $revision );
-                                       } else {
-                                               wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
-                                       }
-                               }
-                       }
-               }
+               $revision = $revision->getRevisionRecord();
 
-               if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
-                       MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content );
-               }
+               $updater = $this->getDerivedDataUpdater( $user, $revision );
 
-               if ( $options['created'] ) {
-                       self::onArticleCreate( $this->mTitle );
-               } elseif ( $options['changed'] ) { // T52785
-                       self::onArticleEdit( $this->mTitle, $revision );
-               }
+               $updater->prepareUpdate( $revision, $options );
 
-               ResourceLoaderWikiModule::invalidateModuleCache(
-                       $this->mTitle, $options['oldrevision'], $revision, wfWikiID()
-               );
+               $updater->doUpdates();
        }
 
        /**
@@ -2380,7 +1982,7 @@ class WikiPage implements Page, IDBAccessObject {
                // Take this opportunity to purge out expired restrictions
                Title::purgeExpiredRestrictions();
 
-               // @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
+               // @todo: Same limitations as described in ProtectionForm.php (line 37);
                // we expect a single selection, but the schema allows otherwise.
                $isProtected = false;
                $protect = false;
@@ -3379,6 +2981,8 @@ class WikiPage implements Page, IDBAccessObject {
         * @param Title $title
         */
        public static function onArticleCreate( Title $title ) {
+               // TODO: move this into a PageEventEmitter service
+
                // Update existence markers on article/talk tabs...
                $other = $title->getOtherPage();
 
@@ -3410,6 +3014,8 @@ class WikiPage implements Page, IDBAccessObject {
         * @param Title $title
         */
        public static function onArticleDelete( Title $title ) {
+               // TODO: move this into a PageEventEmitter service
+
                // Update existence markers on article/talk tabs...
                // Clear Backlink cache first so that purge jobs use more up-to-date backlink information
                BacklinkCache::get( $title )->clear();
@@ -3455,12 +3061,24 @@ class WikiPage implements Page, IDBAccessObject {
         *
         * @param Title $title
         * @param Revision|null $revision Revision that was just saved, may be null
+        * @param string[]|null $slotsChanged The role names of the slots that were changed.
+        *        If not given, all slots are assumed to have changed.
         */
-       public static function onArticleEdit( Title $title, Revision $revision = null ) {
-               // Invalidate caches of articles which include this page
-               DeferredUpdates::addUpdate(
-                       new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' )
-               );
+       public static function onArticleEdit(
+               Title $title,
+               Revision $revision = null,
+               $slotsChanged = null
+       ) {
+               // TODO: move this into a PageEventEmitter service
+
+               if ( $slotsChanged === null || in_array( 'main',  $slotsChanged ) ) {
+                       // Invalidate caches of articles which include this page.
+                       // Only for the main slot, because only the main slot is transcluded.
+                       // TODO: MCR: not true for TemplateStyles! [SlotHandler]
+                       DeferredUpdates::addUpdate(
+                               new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' )
+                       );
+               }
 
                // Invalidate the caches of all pages which redirect here
                DeferredUpdates::addUpdate(
@@ -3781,4 +3399,5 @@ class WikiPage implements Page, IDBAccessObject {
 
                return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() );
        }
+
 }
index 53ae435..ff5de0d 100644 (file)
@@ -534,6 +534,8 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
        ) {
                static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ];
 
+               // TODO: MCR: differentiate between page functionality and content model!
+               //       Not all pages containing CSS or JS have to be modules! [PageType]
                if ( $old && in_array( $old->getContentFormat(), $formats ) ) {
                        $purge = true;
                } elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) {
index c5fa05a..7c88598 100644 (file)
@@ -636,6 +636,27 @@ class User implements IDBAccessObject, UserIdentity {
                return $u;
        }
 
+       /**
+        * Returns a User object corresponding to the given UserIdentity.
+        *
+        * @since 1.32
+        *
+        * @param UserIdentity $identity
+        *
+        * @return User
+        */
+       public static function newFromIdentity( UserIdentity $identity ) {
+               if ( $identity instanceof User ) {
+                       return $identity;
+               }
+
+               return self::newFromAnyId(
+                       $identity->getId() === 0 ? null : $identity->getId(),
+                       $identity->getName() === '' ? null : $identity->getName(),
+                       $identity->getActorId() === 0 ? null : $identity->getActorId()
+               );
+       }
+
        /**
         * Static factory method for creation from an ID, name, and/or actor ID
         *
diff --git a/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php b/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php
new file mode 100644 (file)
index 0000000..2924812
--- /dev/null
@@ -0,0 +1,746 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Content;
+use LinksUpdate;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\DerivedPageDataUpdater;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use Title;
+use User;
+use Wikimedia\TestingAccessWrapper;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Database
+ *
+ * @covers MediaWiki\Storage\DerivedPageDataUpdater
+ */
+class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
+
+       /**
+        * @param string $title
+        *
+        * @return Title
+        */
+       private function getTitle( $title ) {
+               return Title::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
+       }
+
+       /**
+        * @param string|Title $title
+        *
+        * @return WikiPage
+        */
+       private function getPage( $title ) {
+               $title = ( $title instanceof Title ) ? $title : $this->getTitle( $title );
+
+               return WikiPage::factory( $title );
+       }
+
+       /**
+        * @param string|Title|WikiPage $page
+        *
+        * @return DerivedPageDataUpdater
+        */
+       private function getDerivedPageDataUpdater( $page, RevisionRecord $rec = null ) {
+               if ( is_string( $page ) || $page instanceof Title ) {
+                       $page = $this->getPage( $page );
+               }
+
+               $page = TestingAccessWrapper::newFromObject( $page );
+               return $page->getDerivedDataUpdater( null, $rec );
+       }
+
+       /**
+        * Creates a revision in the database.
+        *
+        * @param WikiPage $page
+        * @param $summary
+        * @param null|string|Content $content
+        *
+        * @return RevisionRecord|null
+        */
+       private function createRevision( WikiPage $page, $summary, $content = null ) {
+               $user = $this->getTestUser()->getUser();
+               $comment = CommentStoreComment::newUnsavedComment( $summary );
+
+               if ( !$content instanceof Content ) {
+                       $content = new WikitextContent( $content === null ? $summary : $content );
+               }
+
+               $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', $content );
+               $rev = $updater->saveRevision( $comment );
+
+               $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
+               return $rev;
+       }
+
+       // TODO: test setArticleCountMethod() and isCountable();
+       // TODO: test isRedirect() and wasRedirect()
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
+        */
+       public function testGetCanonicalParserOptions() {
+               global $wgContLang;
+
+               $user = $this->getTestUser()->getUser();
+               $page = $this->getPage( __METHOD__ );
+
+               $parentRev = $this->createRevision( $page, 'first' );
+
+               $mainContent = new WikitextContent( 'Lorem ipsum' );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent );
+               $updater = $this->getDerivedPageDataUpdater( $page );
+               $updater->prepareContent( $user, $update, false );
+
+               $options1 = $updater->getCanonicalParserOptions();
+               $this->assertSame( $wgContLang, $options1->getUserLangObj() );
+
+               $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() );
+               $this->assertSame( $parentRev->getId() + 1, $speculativeId );
+
+               $rev = $this->makeRevision(
+                       $page->getTitle(),
+                       $update,
+                       $user,
+                       $parentRev->getId() + 7,
+                       $parentRev->getId()
+               );
+               $updater->prepareUpdate( $rev );
+
+               $options2 = $updater->getCanonicalParserOptions();
+               $this->assertNotSame( $options1, $options2 );
+
+               $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
+               $this->assertSame( $rev->getId(), $currentRev->getId() );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+        */
+       public function testGrabCurrentRevision() {
+               $page = $this->getPage( __METHOD__ );
+
+               $updater0 = $this->getDerivedPageDataUpdater( $page );
+               $this->assertNull( $updater0->grabCurrentRevision() );
+               $this->assertFalse( $updater0->pageExisted() );
+
+               $rev1 = $this->createRevision( $page, 'first' );
+               $updater1 = $this->getDerivedPageDataUpdater( $page );
+               $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
+               $this->assertFalse( $updater0->pageExisted() );
+               $this->assertTrue( $updater1->pageExisted() );
+
+               $rev2 = $this->createRevision( $page, 'second' );
+               $updater2 = $this->getDerivedPageDataUpdater( $page );
+               $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
+               $this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
+        */
+       public function testPrepareContent() {
+               $user = $this->getTestUser()->getUser();
+               $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+
+               $this->assertFalse( $updater->isContentPrepared() );
+
+               // TODO: test stash
+               // TODO: MCR: Test multiple slots. Test slot removal.
+               $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+               $auxContent = new WikitextContent( 'inherited ~~~ content' );
+               $auxSlot = SlotRecord::newSaved(
+                       10, 7, 'tt:7',
+                       SlotRecord::newUnsaved( 'aux', $auxContent )
+               );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent );
+               $update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
+               // TODO: MCR: test removing slots!
+
+               $updater->prepareContent( $user, $update, false );
+
+               // second be ok to call again with the same params
+               $updater->prepareContent( $user, $update, false );
+
+               $this->assertNull( $updater->grabCurrentRevision() );
+               $this->assertTrue( $updater->isContentPrepared() );
+               $this->assertFalse( $updater->isUpdatePrepared() );
+               $this->assertFalse( $updater->pageExisted() );
+               $this->assertTrue( $updater->isCreation() );
+               $this->assertTrue( $updater->isChange() );
+               $this->assertTrue( $updater->isContentPublic() );
+
+               $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
+               $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
+               $this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
+               $this->assertEquals( [ 'main', 'aux' ], $updater->getModifiedSlotRoles() );
+               $this->assertEquals( [ 'main', 'aux' ], $updater->getTouchedSlotRoles() );
+
+               $mainSlot = $updater->getRawSlot( 'main' );
+               $this->assertInstanceOf( SlotRecord::class, $mainSlot );
+               $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
+               $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() );
+
+               $auxSlot = $updater->getRawSlot( 'aux' );
+               $this->assertInstanceOf( SlotRecord::class, $auxSlot );
+               $this->assertContains( '~~~', $auxSlot->getContent()->serialize(), 'No PST should apply.' );
+
+               $mainOutput = $updater->getCanonicalParserOutput();
+               $this->assertContains( 'first', $mainOutput->getText() );
+               $this->assertContains( '<a ', $mainOutput->getText() );
+               $this->assertNotEmpty( $mainOutput->getLinks() );
+
+               $canonicalOutput = $updater->getCanonicalParserOutput();
+               $this->assertContains( 'first', $canonicalOutput->getText() );
+               $this->assertContains( '<a ', $canonicalOutput->getText() );
+               $this->assertNotEmpty( $canonicalOutput->getLinks() );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
+        */
+       public function testPrepareContentInherit() {
+               $user = $this->getTestUser()->getUser();
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+               $mainContent2 = new WikitextContent( 'second' );
+
+               $this->createRevision( $page, 'first', $mainContent1 );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent1 );
+               $updater1 = $this->getDerivedPageDataUpdater( $page );
+               $updater1->prepareContent( $user, $update, false );
+
+               $this->assertNotNull( $updater1->grabCurrentRevision() );
+               $this->assertTrue( $updater1->isContentPrepared() );
+               $this->assertTrue( $updater1->pageExisted() );
+               $this->assertFalse( $updater1->isCreation() );
+               $this->assertFalse( $updater1->isChange() );
+
+               // TODO: MCR: test inheritance from parent
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent2 );
+               $updater2 = $this->getDerivedPageDataUpdater( $page );
+               $updater2->prepareContent( $user, $update, false );
+
+               $this->assertFalse( $updater2->isCreation() );
+               $this->assertTrue( $updater2->isChange() );
+       }
+
+       // TODO: test failure of prepareContent() when called again...
+       // - with different user
+       // - with different update
+       // - after calling prepareUpdate()
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
+        */
+       public function testPrepareUpdate() {
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+               $rev1 = $this->createRevision( $page, 'first', $mainContent1 );
+               $updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );
+
+               $options = []; // TODO: test *all* the options...
+               $updater1->prepareUpdate( $rev1, $options );
+
+               $this->assertTrue( $updater1->isUpdatePrepared() );
+               $this->assertTrue( $updater1->isContentPrepared() );
+               $this->assertTrue( $updater1->isCreation() );
+               $this->assertTrue( $updater1->isChange() );
+               $this->assertTrue( $updater1->isContentPublic() );
+
+               $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
+               $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
+               $this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
+               $this->assertEquals( [ 'main' ], $updater1->getModifiedSlotRoles() );
+               $this->assertEquals( [ 'main' ], $updater1->getTouchedSlotRoles() );
+
+               // TODO: MCR: test multiple slots, test slot removal!
+
+               $this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( 'main' ) );
+               $this->assertNotContains( '~~~~', $updater1->getRawContent( 'main' )->serialize() );
+
+               $mainOutput = $updater1->getCanonicalParserOutput();
+               $this->assertContains( 'first', $mainOutput->getText() );
+               $this->assertContains( '<a ', $mainOutput->getText() );
+               $this->assertNotEmpty( $mainOutput->getLinks() );
+
+               $canonicalOutput = $updater1->getCanonicalParserOutput();
+               $this->assertContains( 'first', $canonicalOutput->getText() );
+               $this->assertContains( '<a ', $canonicalOutput->getText() );
+               $this->assertNotEmpty( $canonicalOutput->getLinks() );
+
+               $mainContent2 = new WikitextContent( 'second' );
+               $rev2 = $this->createRevision( $page, 'second', $mainContent2 );
+               $updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );
+
+               $options = []; // TODO: test *all* the options...
+               $updater2->prepareUpdate( $rev2, $options );
+
+               $this->assertFalse( $updater2->isCreation() );
+               $this->assertTrue( $updater2->isChange() );
+
+               $canonicalOutput = $updater2->getCanonicalParserOutput();
+               $this->assertContains( 'second', $canonicalOutput->getText() );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+        */
+       public function testPrepareUpdateReusesParserOutput() {
+               $user = $this->getTestUser()->getUser();
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent1 );
+               $updater = $this->getDerivedPageDataUpdater( $page );
+               $updater->prepareContent( $user, $update, false );
+
+               $mainOutput = $updater->getSlotParserOutput( 'main' );
+               $canonicalOutput = $updater->getCanonicalParserOutput();
+
+               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+
+               $options = []; // TODO: test *all* the options...
+               $updater->prepareUpdate( $rev, $options );
+
+               $this->assertTrue( $updater->isUpdatePrepared() );
+               $this->assertTrue( $updater->isContentPrepared() );
+
+               $this->assertSame( $mainOutput, $updater->getSlotParserOutput( 'main' ) );
+               $this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+        */
+       public function testPrepareUpdateOutputReset() {
+               $user = $this->getTestUser()->getUser();
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent1 );
+               $updater = $this->getDerivedPageDataUpdater( $page );
+               $updater->prepareContent( $user, $update, false );
+
+               $mainOutput = $updater->getSlotParserOutput( 'main' );
+               $canonicalOutput = $updater->getCanonicalParserOutput();
+
+               // prevent optimization on matching speculative ID
+               $mainOutput->setSpeculativeRevIdUsed( 0 );
+               $canonicalOutput->setSpeculativeRevIdUsed( 0 );
+
+               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+
+               $options = []; // TODO: test *all* the options...
+               $updater->prepareUpdate( $rev, $options );
+
+               $this->assertTrue( $updater->isUpdatePrepared() );
+               $this->assertTrue( $updater->isContentPrepared() );
+
+               // ParserOutput objects should have been flushed.
+               $this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( 'main' ) );
+               $this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
+
+               $html = $updater->getCanonicalParserOutput()->getText();
+               $this->assertContains( '--' . $rev->getId() . '--', $html );
+
+               // TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
+               // updated, the main slot is still re-rendered!
+       }
+
+       // TODO: test failure of prepareUpdate() when called again with a different revision
+       // TODO: test failure of prepareUpdate() on inconsistency with prepareContent.
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
+        */
+       public function testGetPreparedEditAfterPrepareContent() {
+               $user = $this->getTestUser()->getUser();
+
+               $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent );
+
+               $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+               $updater->prepareContent( $user, $update, false );
+
+               $canonicalOutput = $updater->getCanonicalParserOutput();
+
+               $preparedEdit = $updater->getPreparedEdit();
+               $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
+               $this->assertSame( $canonicalOutput, $preparedEdit->output );
+               $this->assertSame( $mainContent, $preparedEdit->newContent );
+               $this->assertSame( $updater->getRawContent( 'main' ), $preparedEdit->pstContent );
+               $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
+               $this->assertSame( null, $preparedEdit->revid );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
+        */
+       public function testGetPreparedEditAfterPrepareUpdate() {
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+               $update = new MutableRevisionSlots();
+               $update->setContent( 'main', $mainContent );
+
+               $rev = $this->createRevision( $page, __METHOD__ );
+
+               $updater = $this->getDerivedPageDataUpdater( $page );
+               $updater->prepareUpdate( $rev );
+
+               $canonicalOutput = $updater->getCanonicalParserOutput();
+
+               $preparedEdit = $updater->getPreparedEdit();
+               $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
+               $this->assertSame( $canonicalOutput, $preparedEdit->output );
+               $this->assertSame( $updater->getRawContent( 'main' ), $preparedEdit->pstContent );
+               $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
+               $this->assertSame( $rev->getId(), $preparedEdit->revid );
+       }
+
+       public function testGetSecondaryDataUpdatesAfterPrepareContent() {
+               $user = $this->getTestUser()->getUser();
+               $page = $this->getPage( __METHOD__ );
+               $this->createRevision( $page, __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first' );
+
+               $update = new RevisionSlotsUpdate();
+               $update->modifyContent( 'main', $mainContent1 );
+               $updater = $this->getDerivedPageDataUpdater( $page );
+               $updater->prepareContent( $user, $update, false );
+
+               $dataUpdates = $updater->getSecondaryDataUpdates();
+
+               // TODO: MCR: assert updates from all slots!
+               $this->assertNotEmpty( $dataUpdates );
+
+               $linksUpdates = array_filter( $dataUpdates, function ( $du ) {
+                       return $du instanceof LinksUpdate;
+               } );
+               $this->assertCount( 1, $linksUpdates );
+       }
+
+       /**
+        * Creates a dummy revision object without touching the database.
+        *
+        * @param Title $title
+        * @param RevisionSlotsUpdate $update
+        * @param User $user
+        * @param string $comment
+        * @param int $id
+        * @param int $parentId
+        *
+        * @return MutableRevisionRecord
+        */
+       private function makeRevision(
+               Title $title,
+               RevisionSlotsUpdate $update,
+               User $user,
+               $comment,
+               $id,
+               $parentId = 0
+       ) {
+               $rev = new MutableRevisionRecord( $title );
+
+               $rev->applyUpdate( $update );
+               $rev->setUser( $user );
+               $rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
+               $rev->setId( $id );
+               $rev->setPageId( $title->getArticleID() );
+               $rev->setParentId( $parentId );
+
+               return $rev;
+       }
+
+       public function provideIsReusableFor() {
+               $title = Title::makeTitleSafe( NS_MAIN, __METHOD__ );
+
+               $user1 = User::newFromName( 'Alice' );
+               $user2 = User::newFromName( 'Bob' );
+
+               $content1 = new WikitextContent( 'one' );
+               $content2 = new WikitextContent( 'two' );
+
+               $update1 = new RevisionSlotsUpdate();
+               $update1->modifyContent( 'main', $content1 );
+
+               $update1b = new RevisionSlotsUpdate();
+               $update1b->modifyContent( 'xyz', $content1 );
+
+               $update2 = new RevisionSlotsUpdate();
+               $update2->modifyContent( 'main', $content2 );
+
+               $rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
+               $rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );
+
+               $rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
+               $rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
+               $rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );
+
+               yield 'any' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => null,
+                       '$forUser' => null,
+                       '$forRevision' => null,
+                       '$forUpdate' => null,
+                       '$forParent' => null,
+                       '$isReusable' => true,
+               ];
+               yield 'for any' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => $rev1,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => null,
+                       '$forRevision' => null,
+                       '$forUpdate' => null,
+                       '$forParent' => null,
+                       '$isReusable' => true,
+               ];
+               yield 'unprepared' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => null,
+                       '$forUser' => $user1,
+                       '$forRevision' => $rev1,
+                       '$forUpdate' => $update1,
+                       '$forParent' => 0,
+                       '$isReusable' => true,
+               ];
+               yield 'match prepareContent' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => $user1,
+                       '$forRevision' => null,
+                       '$forUpdate' => $update1,
+                       '$forParent' => 0,
+                       '$isReusable' => true,
+               ];
+               yield 'match prepareUpdate' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => $rev1,
+                       '$prepUpdate' => null,
+                       '$forUser' => $user1,
+                       '$forRevision' => $rev1,
+                       '$forUpdate' => null,
+                       '$forParent' => 0,
+                       '$isReusable' => true,
+               ];
+               yield 'match all' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => $rev1,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => $user1,
+                       '$forRevision' => $rev1,
+                       '$forUpdate' => $update1,
+                       '$forParent' => 0,
+                       '$isReusable' => true,
+               ];
+               yield 'mismatch prepareContent update' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => $user1,
+                       '$forRevision' => null,
+                       '$forUpdate' => $update1b,
+                       '$forParent' => 0,
+                       '$isReusable' => false,
+               ];
+               yield 'mismatch prepareContent user' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => $user2,
+                       '$forRevision' => null,
+                       '$forUpdate' => $update1,
+                       '$forParent' => 0,
+                       '$isReusable' => false,
+               ];
+               yield 'mismatch prepareContent parent' => [
+                       '$prepUser' => $user1,
+                       '$prepRevision' => null,
+                       '$prepUpdate' => $update1,
+                       '$forUser' => $user1,
+                       '$forRevision' => null,
+                       '$forUpdate' => $update1,
+                       '$forParent' => 7,
+                       '$isReusable' => false,
+               ];
+               yield 'mismatch prepareUpdate revision update' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => $rev1,
+                       '$prepUpdate' => null,
+                       '$forUser' => null,
+                       '$forRevision' => $rev1b,
+                       '$forUpdate' => null,
+                       '$forParent' => 0,
+                       '$isReusable' => false,
+               ];
+               yield 'mismatch prepareUpdate revision user' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => $rev2,
+                       '$prepUpdate' => null,
+                       '$forUser' => null,
+                       '$forRevision' => $rev2x,
+                       '$forUpdate' => null,
+                       '$forParent' => 0,
+                       '$isReusable' => false,
+               ];
+               yield 'mismatch prepareUpdate revision id' => [
+                       '$prepUser' => null,
+                       '$prepRevision' => $rev2,
+                       '$prepUpdate' => null,
+                       '$forUser' => null,
+                       '$forRevision' => $rev2y,
+                       '$forUpdate' => null,
+                       '$forParent' => 0,
+                       '$isReusable' => false,
+               ];
+       }
+
+       /**
+        * @dataProvider provideIsReusableFor
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
+        *
+        * @param User|null $prepUser
+        * @param RevisionRecord|null $prepRevision
+        * @param RevisionSlotsUpdate|null $prepUpdate
+        * @param User|null $forUser
+        * @param RevisionRecord|null $forRevision
+        * @param RevisionSlotsUpdate|null $forUpdate
+        * @param int|null $forParent
+        * @param bool $isReusable
+        */
+       public function testIsReusableFor(
+               User $prepUser = null,
+               RevisionRecord $prepRevision = null,
+               RevisionSlotsUpdate $prepUpdate = null,
+               User $forUser = null,
+               RevisionRecord $forRevision = null,
+               RevisionSlotsUpdate $forUpdate = null,
+               $forParent = null,
+               $isReusable = null
+       ) {
+               $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+
+               if ( $prepUpdate ) {
+                       $updater->prepareContent( $prepUser, $prepUpdate, false );
+               }
+
+               if ( $prepRevision ) {
+                       $updater->prepareUpdate( $prepRevision );
+               }
+
+               $this->assertSame(
+                       $isReusable,
+                       $updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
+               );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
+        */
+       public function testDoUpdates() {
+               $page = $this->getPage( __METHOD__ );
+
+               $mainContent1 = new WikitextContent( 'first [[main]]' );
+               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+               $pageId = $page->getId();
+               $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+               $updater = $this->getDerivedPageDataUpdater( $page, $rev );
+               $updater->setArticleCountMethod( 'link' );
+
+               $options = []; // TODO: test *all* the options...
+               $updater->prepareUpdate( $rev, $options );
+
+               $updater->doUpdates();
+
+               // links table update
+               $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+               $this->assertSame( 1, $linkCount );
+
+               $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+               $this->assertInternalType( 'object', $pageLinksRow );
+               $this->assertSame( 'Main', $pageLinksRow->pl_title );
+
+               // parser cache update
+               $pcache = MediaWikiServices::getInstance()->getParserCache();
+               $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
+               $this->assertInternalType( 'object', $cached );
+               $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
+
+               // site stats
+               $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+               $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
+               $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
+               $this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles );
+
+               // TODO: MCR: test data updates for additional slots!
+               // TODO: test update for edit without page creation
+               // TODO: test message cache purge
+               // TODO: test module cache purge
+               // TODO: test CDN purge
+               // TODO: test newtalk update
+               // TODO: test search update
+               // TODO: test site stats good_articles while turning the page into (or back from) a redir.
+               // TODO: test category membership update (with setRcWatchCategoryMembership())
+       }
+
+}
index dd2c4b6..62093f0 100644 (file)
@@ -7,6 +7,7 @@ use InvalidArgumentException;
 use MediaWiki\Storage\MutableRevisionRecord;
 use MediaWiki\Storage\RevisionAccessException;
 use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
 use MediaWiki\Storage\SlotRecord;
 use MediaWiki\User\UserIdentityValue;
 use MediaWikiTestCase;
@@ -209,4 +210,82 @@ class MutableRevisionRecordTest extends MediaWikiTestCase {
                $this->assertSame( $comment, $record->getComment() );
        }
 
+       public function testSimpleGetOriginalAndInheritedSlots() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $mainSlot = new SlotRecord(
+                       (object)[
+                               'slot_id' => 1,
+                               'slot_revision_id' => null, // unsaved
+                               'slot_content_id' => 1,
+                               'content_address' => null, // touched
+                               'model_name' => 'x',
+                               'role_name' => 'main',
+                               'slot_origin' => null // touched
+                       ],
+                       new WikitextContent( 'main' )
+               );
+               $auxSlot = new SlotRecord(
+                       (object)[
+                               'slot_id' => 2,
+                               'slot_revision_id' => null, // unsaved
+                               'slot_content_id' => 1,
+                               'content_address' => 'foo', // inherited
+                               'model_name' => 'x',
+                               'role_name' => 'aux',
+                               'slot_origin' => 1 // inherited
+                       ],
+                       new WikitextContent( 'aux' )
+               );
+
+               $record->setSlot( $mainSlot );
+               $record->setSlot( $auxSlot );
+
+               $this->assertSame( [ 'main' ], $record->getOriginalSlots()->getSlotRoles() );
+               $this->assertSame( $mainSlot, $record->getOriginalSlots()->getSlot( 'main' ) );
+
+               $this->assertSame( [ 'aux' ], $record->getInheritedSlots()->getSlotRoles() );
+               $this->assertSame( $auxSlot, $record->getInheritedSlots()->getSlot( 'aux' ) );
+       }
+
+       public function testSimpleremoveSlot() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+
+               $a = new WikitextContent( 'a' );
+               $b = new WikitextContent( 'b' );
+
+               $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+               $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+
+               $record->removeSlot( 'b' );
+
+               $this->assertTrue( $record->hasSlot( 'a' ) );
+               $this->assertFalse( $record->hasSlot( 'b' ) );
+       }
+
+       public function testApplyUpdate() {
+               $update = new RevisionSlotsUpdate();
+
+               $a = new WikitextContent( 'a' );
+               $b = new WikitextContent( 'b' );
+               $c = new WikitextContent( 'c' );
+               $x = new WikitextContent( 'x' );
+
+               $update->modifyContent( 'b', $x );
+               $update->modifyContent( 'c', $x );
+               $update->removeSlot( 'c' );
+               $update->removeSlot( 'd' );
+
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+               $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+               $record->inheritSlot( SlotRecord::newSaved( 7, 5, 'c', SlotRecord::newUnsaved( 'c', $c ) ) );
+
+               $record->applyUpdate( $update );
+
+               $this->assertEquals( [ 'b' ], array_keys( $record->getOriginalSlots()->getSlots() ) );
+               $this->assertEquals( $a, $record->getSlot( 'a' )->getContent() );
+               $this->assertEquals( $x, $record->getSlot( 'b' )->getContent() );
+               $this->assertFalse( $record->hasSlot( 'c' ) );
+       }
+
 }
index f19be3b..5a83143 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace MediaWiki\Tests\Storage;
 
+use Content;
 use InvalidArgumentException;
 use MediaWiki\Storage\MutableRevisionSlots;
 use MediaWiki\Storage\RevisionAccessException;
@@ -75,6 +76,32 @@ class MutableRevisionSlotsTest extends RevisionSlotsTest {
                $this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
        }
 
+       /**
+        * @param string $role
+        * @param Content $content
+        * @return SlotRecord
+        */
+       private function newSavedSlot( $role, Content $content ) {
+               return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
+       }
+
+       public function testInheritSlotOverwritesSlot() {
+               $slots = new MutableRevisionSlots();
+               $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $slots->setSlot( $slotA );
+               $slotB = $this->newSavedSlot( 'main', new WikitextContent( 'B' ) );
+               $slotC = $this->newSavedSlot( 'foo', new WikitextContent( 'C' ) );
+               $slots->inheritSlot( $slotB );
+               $slots->inheritSlot( $slotC );
+               $this->assertSame( [ 'main', 'foo' ], $slots->getSlotRoles() );
+               $this->assertNotSame( $slotB, $slots->getSlot( 'main' ) );
+               $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) );
+               $this->assertTrue( $slots->getSlot( 'main' )->isInherited() );
+               $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() );
+               $this->assertSame( $slotB->getContent(), $slots->getSlot( 'main' )->getContent() );
+               $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() );
+       }
+
        public function testSetContentOfExistingSlotOverwritesContent() {
                $slots = new MutableRevisionSlots();
 
@@ -102,4 +129,20 @@ class MutableRevisionSlotsTest extends RevisionSlotsTest {
                $slots->getSlot( 'main' );
        }
 
+       public function testNewFromParentRevisionSlots() {
+               /** @var SlotRecord[] $parentSlots */
+               $parentSlots = [
+                       'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ),
+                       'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ),
+               ];
+               $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
+               $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() );
+               $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) );
+               $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) );
+               $this->assertTrue( $slots->getSlot( 'some' )->isInherited() );
+               $this->assertTrue( $slots->getSlot( 'other' )->isInherited() );
+               $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) );
+               $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) );
+       }
+
 }
diff --git a/tests/phpunit/includes/Storage/PageUpdaterTest.php b/tests/phpunit/includes/Storage/PageUpdaterTest.php
new file mode 100644 (file)
index 0000000..24107b1
--- /dev/null
@@ -0,0 +1,530 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Content;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWikiTestCase;
+use RecentChange;
+use Revision;
+use TextContent;
+use Title;
+use WikiPage;
+
+/**
+ * @covers \MediaWiki\Storage\PageUpdater
+ * @group Database
+ */
+class PageUpdaterTest extends MediaWikiTestCase {
+
+       private function getDummyTitle( $method ) {
+               return Title::newFromText( $method, $this->getDefaultWikitextNS() );
+       }
+
+       /**
+        * @param int $revId
+        *
+        * @return null|RecentChange
+        */
+       private function getRecentChangeFor( $revId ) {
+               $qi = RecentChange::getQueryInfo();
+               $row = $this->db->selectRow(
+                       $qi['tables'],
+                       $qi['fields'],
+                       [ 'rc_this_oldid' => $revId ],
+                       __METHOD__,
+                       [],
+                       $qi['joins']
+               );
+
+               return $row ? RecentChange::newFromRow( $row ) : null;
+       }
+
+       // TODO: test setAjaxEditStash();
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        * @covers \WikiPage::newPageUpdater()
+        */
+       public function testCreatePage() {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+               $this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
+               $this->assertFalse( $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+               $this->assertSame( 0, $updater->getUndidRevisionId(), 'getUndidRevisionId' );
+
+               $updater->setBaseRevisionId( 0 );
+               $this->assertSame( 0, $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+
+               $updater->addTag( 'foo' );
+               $updater->addTags( [ 'bar', 'qux' ] );
+
+               $tags = $updater->getExplicitTags();
+               sort( $tags );
+               $this->assertSame( [ 'bar', 'foo', 'qux' ], $tags, 'getExplicitTags' );
+
+               // TODO: MCR: test additional slots
+               $content = new TextContent( 'Lorem Ipsum' );
+               $updater->setContent( 'main', $content );
+
+               $parent = $updater->grabParentRevision();
+
+               // TODO: test that hasEditConflict() grabs the parent revision
+               $this->assertNull( $parent, 'getParentRevision' );
+               $this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
+               $this->assertFalse( $updater->hasEditConflict(), 'hasEditConflict' );
+
+               // TODO: test failure with EDIT_UPDATE
+               // TODO: test EDIT_MINOR, EDIT_BOT, etc
+               $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+               $rev = $updater->saveRevision( $summary );
+
+               $this->assertNotNull( $rev );
+               $this->assertSame( 0, $rev->getParentId() );
+               $this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
+               $this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
+
+               $this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
+               $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $updater->isNew(), 'isNew()' );
+               $this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
+               $this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertInstanceOf( Revision::class, $updater->getStatus()->value['revision'] );
+
+               $rev = $updater->getNewRevision();
+               $revContent = $rev->getContent( 'main' );
+               $this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
+
+               // were the WikiPage and Title objects updated?
+               $this->assertTrue( $page->exists(), 'WikiPage::exists()' );
+               $this->assertTrue( $title->exists(), 'Title::exists()' );
+               $this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getRevision()' );
+               $this->assertNotNull( $page->getRevision(), 'WikiPage::getRevision()' );
+
+               // re-load
+               $page2 = WikiPage::factory( $title );
+               $this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
+               $this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getRevision()' );
+               $this->assertNotNull( $page2->getRevision(), 'WikiPage::getRevision()' );
+
+               // Check RC entry
+               $rc = $this->getRecentChangeFor( $rev->getId() );
+               $this->assertNotNull( $rc, 'RecentChange' );
+
+               // check site stats - this asserts that derived data updates where run.
+               $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+               $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
+               $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
+
+               // re-edit with same content - should be a "null-edit"
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', $content );
+
+               $summary = CommentStoreComment::newUnsavedComment( 'to to re-edit' );
+               $rev = $updater->saveRevision( $summary );
+               $status = $updater->getStatus();
+
+               $this->assertNull( $rev, 'getNewRevision()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertTrue( $updater->isUnchanged(), 'isUnchanged' );
+               $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertTrue( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-no-change' ), 'edit-no-change' );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        * @covers \WikiPage::newPageUpdater()
+        */
+       public function testUpdatePage() {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+               $this->insertPage( $title );
+
+               $page = WikiPage::factory( $title );
+               $parentId = $page->getLatest();
+
+               $updater = $page->newPageUpdater( $user );
+
+               $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+               // TODO: test page update does not fail with mismatching base rev ID
+               $baseRev = $title->getLatestRevID( Title::GAID_FOR_UPDATE );
+               $updater->setBaseRevisionId( $baseRev );
+               $this->assertSame( $baseRev, $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+
+               // TODO: MCR: test additional slots
+               $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+               // TODO: test all flags for saveRevision()!
+               $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+               $rev = $updater->saveRevision( $summary );
+
+               $this->assertNotNull( $rev );
+               $this->assertSame( $parentId, $rev->getParentId() );
+               $this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
+               $this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
+
+               $this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
+               $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+               $this->assertFalse( $updater->isNew(), 'isNew()' );
+               $this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertInstanceOf( Revision::class, $updater->getStatus()->value['revision'] );
+               $this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
+
+               // TODO: Test null revision (with different user): new revision!
+
+               $rev = $updater->getNewRevision();
+               $revContent = $rev->getContent( 'main' );
+               $this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
+
+               // were the WikiPage and Title objects updated?
+               $this->assertTrue( $page->exists(), 'WikiPage::exists()' );
+               $this->assertTrue( $title->exists(), 'Title::exists()' );
+               $this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getRevision()' );
+               $this->assertNotNull( $page->getRevision(), 'WikiPage::getRevision()' );
+
+               // re-load
+               $page2 = WikiPage::factory( $title );
+               $this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
+               $this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getRevision()' );
+               $this->assertNotNull( $page2->getRevision(), 'WikiPage::getRevision()' );
+
+               // Check RC entry
+               $rc = $this->getRecentChangeFor( $rev->getId() );
+               $this->assertNotNull( $rc, 'RecentChange' );
+
+               // re-edit
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( 're-edit' );
+               $updater->saveRevision( $summary );
+               $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+
+               // check site stats - this asserts that derived data updates where run.
+               $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+               $this->assertSame( $oldStats->ss_total_pages + 0, (int)$stats->ss_total_pages );
+               $this->assertSame( $oldStats->ss_total_edits + 2, (int)$stats->ss_total_edits );
+       }
+
+       /**
+        * Creates a revision in the database.
+        *
+        * @param WikiPage $page
+        * @param $summary
+        * @param null|string|Content $content
+        *
+        * @return RevisionRecord|null
+        */
+       private function createRevision( WikiPage $page, $summary, $content = null ) {
+               $user = $this->getTestUser()->getUser();
+               $comment = CommentStoreComment::newUnsavedComment( $summary );
+
+               if ( !$content instanceof Content ) {
+                       $content = new TextContent( $content === null ? $summary : $content );
+               }
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', $content );
+               $rev = $updater->saveRevision( $comment );
+               return $rev;
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::grabParentRevision()
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        */
+       public function testCompareAndSwapFailure() {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+
+               // start editing non-existing page
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+               $updater->grabParentRevision();
+
+               // create page concurrently
+               $concurrentPage = WikiPage::factory( $title );
+               $this->createRevision( $concurrentPage, __METHOD__ . '-one' );
+
+               // try creating the page - should trigger CAS failure.
+               $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+               $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+               $updater->saveRevision( $summary );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-conflict' );
+
+               // start editing existing page
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+               $updater->grabParentRevision();
+
+               // update page concurrently
+               $concurrentPage = WikiPage::factory( $title );
+               $this->createRevision( $concurrentPage, __METHOD__ . '-two' );
+
+               // try creating the page - should trigger CAS failure.
+               $summary = CommentStoreComment::newUnsavedComment( 'edit?!' );
+               $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+               $updater->saveRevision( $summary );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-conflict' ), 'edit-conflict' );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        */
+       public function testFailureOnEditFlags() {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+
+               // start editing non-existing page
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               // update with EDIT_UPDATE flag should fail
+               $summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
+               $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+               $updater->saveRevision( $summary, EDIT_UPDATE );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-gone-missing' ), 'edit-gone-missing' );
+
+               // create the page
+               $this->createRevision( $page, __METHOD__ );
+
+               // update with EDIT_NEW flag should fail
+               $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+               $updater->saveRevision( $summary, EDIT_NEW );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        * @covers \MediaWiki\Storage\PageUpdater::setBaseRevisionId()
+        */
+       public function testFailureOnBaseRevision() {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+
+               // start editing non-existing page
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               // update for base revision 7 should fail
+               $summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
+               $updater->setBaseRevisionId( 7 ); // expect page to exist
+               $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+               $updater->saveRevision( $summary );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-gone-missing' ), 'edit-gone-missing' );
+
+               // create the page
+               $this->createRevision( $page, __METHOD__ );
+
+               // update for base revision 0 should fail
+               $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+               $updater = $page->newPageUpdater( $user );
+               $updater->setBaseRevisionId( 0 ); // expect page to not exist
+               $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+               $updater->saveRevision( $summary );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
+       }
+
+       public function provideSetRcPatrolStatus( $patrolled ) {
+               yield [ RecentChange::PRC_UNPATROLLED ];
+               yield [ RecentChange::PRC_AUTOPATROLLED ];
+       }
+
+       /**
+        * @dataProvider provideSetRcPatrolStatus
+        * @covers \MediaWiki\Storage\PageUpdater::setRcPatrolStatus()
+        */
+       public function testSetRcPatrolStatus( $patrolled ) {
+               $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               $summary = CommentStoreComment::newUnsavedComment( 'Lorem ipsum ' . $patrolled );
+               $updater->setContent( 'main', new TextContent( 'Lorem ipsum ' . $patrolled ) );
+               $updater->setRcPatrolStatus( $patrolled );
+               $rev = $updater->saveRevision( $summary );
+
+               $rc = $revisionStore->getRecentChange( $rev );
+               $this->assertEquals( $patrolled, $rc->getAttribute( 'rc_patrolled' ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::inheritSlot()
+        * @covers \MediaWiki\Storage\PageUpdater::setContent()
+        */
+       public function testInheritSlot() {
+               $user = $this->getTestUser()->getUser();
+               $title = $this->getDummyTitle( __METHOD__ );
+               $page = WikiPage::factory( $title );
+
+               $updater = $page->newPageUpdater( $user );
+               $summary = CommentStoreComment::newUnsavedComment( 'one' );
+               $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+               $rev1 = $updater->saveRevision( $summary, EDIT_NEW );
+
+               $updater = $page->newPageUpdater( $user );
+               $summary = CommentStoreComment::newUnsavedComment( 'two' );
+               $updater->setContent( 'main', new TextContent( 'Foo Bar' ) );
+               $rev2 = $updater->saveRevision( $summary, EDIT_UPDATE );
+
+               $updater = $page->newPageUpdater( $user );
+               $summary = CommentStoreComment::newUnsavedComment( 'three' );
+               $updater->inheritSlot( $rev1->getSlot( 'main' ) );
+               $rev3 = $updater->saveRevision( $summary, EDIT_UPDATE );
+
+               $this->assertNotSame( $rev1->getId(), $rev3->getId() );
+               $this->assertNotSame( $rev2->getId(), $rev3->getId() );
+
+               $main1 = $rev1->getSlot( 'main' );
+               $main3 = $rev3->getSlot( 'main' );
+
+               $this->assertNotSame( $main1->getRevision(), $main3->getRevision() );
+               $this->assertSame( $main1->getAddress(), $main3->getAddress() );
+               $this->assertTrue( $main1->getContent()->equals( $main3->getContent() ) );
+       }
+
+       // TODO: MCR: test adding multiple slots, inheriting parent slots, and removing slots.
+
+       public function testSetUseAutomaticEditSummaries() {
+               $this->setContentLang( 'qqx' );
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ );
+               $page = WikiPage::factory( $title );
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setUseAutomaticEditSummaries( true );
+               $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+               // empty comment triggers auto-summary
+               $summary = CommentStoreComment::newUnsavedComment( '' );
+               $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+               $rev = $updater->getNewRevision();
+               $comment = $rev->getComment( RevisionRecord::RAW );
+               $this->assertSame( '(autosumm-new: Lorem Ipsum)', $comment->text, 'comment text' );
+
+               // check that this also works when blanking the page
+               $updater = $page->newPageUpdater( $user );
+               $updater->setUseAutomaticEditSummaries( true );
+               $updater->setContent( 'main', new TextContent( '' ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( '' );
+               $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+               $rev = $updater->getNewRevision();
+               $comment = $rev->getComment( RevisionRecord::RAW );
+               $this->assertSame( '(autosumm-blank)', $comment->text, 'comment text' );
+
+               // check that we can also disable edit-summaries
+               $title2 = $this->getDummyTitle( __METHOD__ . '/2' );
+               $page2 = WikiPage::factory( $title2 );
+
+               $updater = $page2->newPageUpdater( $user );
+               $updater->setUseAutomaticEditSummaries( false );
+               $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( '' );
+               $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+               $rev = $updater->getNewRevision();
+               $comment = $rev->getComment( RevisionRecord::RAW );
+               $this->assertSame( '', $comment->text, 'comment text should still be lank' );
+
+               // check that we don't do auto.summaries without the EDIT_AUTOSUMMARY flag
+               $updater = $page2->newPageUpdater( $user );
+               $updater->setUseAutomaticEditSummaries( true );
+               $updater->setContent( 'main', new TextContent( '' ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( '' );
+               $updater->saveRevision( $summary, 0 );
+
+               $rev = $updater->getNewRevision();
+               $comment = $rev->getComment( RevisionRecord::RAW );
+               $this->assertSame( '', $comment->text, 'comment text' );
+       }
+
+       public function provideSetUsePageCreationLog() {
+               yield [ true, [ [ 'create', 'create' ] ] ];
+               yield [ false, [] ];
+       }
+
+       /**
+        * @dataProvider provideSetUsePageCreationLog
+        * @param bool $use
+        */
+       public function testSetUsePageCreationLog( $use, $expected ) {
+               $user = $this->getTestUser()->getUser();
+               $title = $this->getDummyTitle( __METHOD__ . ( $use ? '_logged' : '_unlogged' ) );
+               $page = WikiPage::factory( $title );
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setUsePageCreationLog( $use );
+               $summary = CommentStoreComment::newUnsavedComment( 'cmt' );
+               $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+               $updater->saveRevision( $summary, EDIT_NEW );
+
+               $rev = $updater->getNewRevision();
+               $this->assertSelect(
+                       'logging',
+                       [ 'log_type', 'log_action' ],
+                       [ 'log_page' => $rev->getPageId() ],
+                       $expected
+               );
+       }
+
+}
index 95bba47..ef14a9e 100644 (file)
@@ -136,9 +136,9 @@ class RevisionSlotsTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers \MediaWiki\Storage\RevisionSlots::getTouchedSlots
+        * @covers \MediaWiki\Storage\RevisionSlots::getOriginalSlots
         */
-       public function testGetTouchedSlots() {
+       public function testGetOriginalSlots() {
                $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
                $auxSlot = SlotRecord::newInherited(
                        SlotRecord::newSaved(
@@ -149,7 +149,7 @@ class RevisionSlotsTest extends MediaWikiTestCase {
                $slotsArray = [ $mainSlot, $auxSlot ];
                $slots = $this->newRevisionSlots( $slotsArray );
 
-               $this->assertEquals( [ 'main' => $mainSlot ], $slots->getTouchedSlots() );
+               $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() );
        }
 
        public function provideComputeSize() {
index 5b392c8..07a6971 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace MediaWiki\Tests\Storage;
 
+use Content;
+use MediaWiki\Storage\MutableRevisionSlots;
 use MediaWiki\Storage\RevisionSlots;
 use MediaWiki\Storage\RevisionSlotsUpdate;
 use MediaWiki\Storage\RevisionAccessException;
@@ -41,8 +43,8 @@ class RevisionSlotsUpdateTest extends MediaWikiTestCase {
         *
         * @param RevisionSlots $newSlots
         * @param RevisionSlots $parentSlots
-        * @param $modified
-        * @param $removed
+        * @param string[] $modified
+        * @param string[] $removed
         */
        public function testNewFromRevisionSlots(
                RevisionSlots $newSlots,
@@ -60,6 +62,44 @@ class RevisionSlotsUpdateTest extends MediaWikiTestCase {
                }
        }
 
+       public function provideNewFromContent() {
+               $slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
+               $slotB = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B' ) );
+               $slotC = SlotRecord::newUnsaved( 'C', new WikitextContent( 'C' ) );
+
+               $parentSlots = new RevisionSlots( [
+                       'A' => $slotA,
+                       'B' => $slotB,
+                       'C' => $slotC,
+               ] );
+
+               $newContent = [
+                       'A' => new WikitextContent( 'A' ),
+                       'B' => new WikitextContent( 'B2' ),
+               ];
+
+               yield [ $newContent, null, [ 'A', 'B' ] ];
+               yield [ $newContent, $parentSlots, [ 'B' ] ];
+       }
+
+       /**
+        * @dataProvider provideNewFromContent
+        *
+        * @param Content[] $newContent
+        * @param RevisionSlots $parentSlots
+        * @param string[] $modified
+        */
+       public function testNewFromContent(
+               array $newContent,
+               RevisionSlots $parentSlots = null,
+               array $modified = []
+       ) {
+               $update = RevisionSlotsUpdate::newFromContent( $newContent, $parentSlots );
+
+               $this->assertEquals( $modified, $update->getModifiedRoles() );
+               $this->assertEmpty( $update->getRemovedRoles() );
+       }
+
        public function testConstructor() {
                $update = new RevisionSlotsUpdate();
 
@@ -204,4 +244,34 @@ class RevisionSlotsUpdateTest extends MediaWikiTestCase {
                $this->assertSame( $same, $b->hasSameUpdates( $a ) );
        }
 
+       /**
+        * @param string $role
+        * @param Content $content
+        * @return SlotRecord
+        */
+       private function newSavedSlot( $role, Content $content ) {
+               return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
+       }
+
+       public function testApplyUpdate() {
+               /** @var SlotRecord[] $parentSlots */
+               $parentSlots = [
+                       'X' => $this->newSavedSlot( 'X', new WikitextContent( 'X' ) ),
+                       'Y' => $this->newSavedSlot( 'Y', new WikitextContent( 'Y' ) ),
+                       'Z' => $this->newSavedSlot( 'Z', new WikitextContent( 'Z' ) ),
+               ];
+               $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
+               $update = RevisionSlotsUpdate::newFromContent( [
+                       'A' => new WikitextContent( 'A' ),
+                       'Y' => new WikitextContent( 'yyy' ),
+               ] );
+
+               $update->removeSlot( 'Z' );
+
+               $update->apply( $slots );
+               $this->assertSame( [ 'X', 'Y', 'A' ], $slots->getSlotRoles() );
+               $this->assertSame( $update->getModifiedSlot( 'A' ), $slots->getSlot( 'A' ) );
+               $this->assertSame( $update->getModifiedSlot( 'Y' ), $slots->getSlot( 'Y' ) );
+       }
+
 }
index 0295e90..1d6a9a0 100644 (file)
@@ -168,7 +168,9 @@ class RevisionStoreRecordTest extends MediaWikiTestCase {
                $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
                $this->assertSame( $comment, $rec->getComment(), 'getComment' );
 
+               $this->assertSame( $slots, $rec->getSlots(), 'getSlots' );
                $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+               $this->assertSame( $slots->getSlots(), $rec->getSlots()->getSlots(), 'getSlots' );
                $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
 
                $this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
index 5f59d6f..6532635 100644 (file)
@@ -89,17 +89,14 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
                User $patrollingUser
        ) {
                $title = Title::newFromLinkTarget( $target );
+               $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
                $page = WikiPage::factory( $title );
-               $status = $page->doEditContent(
-                       ContentHandler::makeContent( $content, $title ),
-                       $summary,
-                       0,
-                       false,
-                       $user
-               );
-               /** @var Revision $rev */
-               $rev = $status->value['revision'];
-               $rc = $rev->getRecentChange();
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', ContentHandler::makeContent( $content, $title ) );
+               $rev = $updater->saveRevision( $summary );
+
+               $rc = MediaWikiServices::getInstance()->getRevisionStore()->getRecentChange( $rev );
                $rc->doMarkPatrolled( $patrollingUser, false, [] );
        }
 
index 3b086b0..6a87dfb 100644 (file)
@@ -1,5 +1,11 @@
 <?php
 
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WikiPage
+ */
 abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
 
        private $pagesToDelete;
@@ -101,29 +107,140 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
         *
         * @return WikiPage
         */
-       protected function createPage( $page, $text, $model = null ) {
+       protected function createPage( $page, $text, $model = null, $user = null ) {
                if ( is_string( $page ) || $page instanceof Title ) {
                        $page = $this->newPage( $page, $model );
                }
 
                $content = ContentHandler::makeContent( $text, $page->getTitle(), $model );
-               $page->doEditContent( $content, "testing", EDIT_NEW );
+               $page->doEditContent( $content, "testing", EDIT_NEW, false, $user );
 
                return $page;
        }
 
        /**
-        * @covers WikiPage::doEditContent
-        * @covers WikiPage::doModify
-        * @covers WikiPage::doCreate
+        * @covers WikiPage::prepareContentForEdit
+        */
+       public function testPrepareContentForEdit() {
+               $user = $this->getTestUser()->getUser();
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
+
+               $page = $this->createPage( __METHOD__, __METHOD__, null, $user );
+               $title = $page->getTitle();
+
+               $content = ContentHandler::makeContent(
+                       "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
+                       . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
+                       $title,
+                       CONTENT_MODEL_WIKITEXT
+               );
+               $content2 = ContentHandler::makeContent(
+                       "At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
+                       . "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
+                       $title,
+                       CONTENT_MODEL_WIKITEXT
+               );
+
+               $edit = $page->prepareContentForEdit( $content, null, $user, null, false );
+
+               $this->assertInstanceOf(
+                       ParserOptions::class,
+                       $edit->popts,
+                       "pops"
+               );
+               $this->assertContains( '</a>', $edit->output->getText(), "output" );
+               $this->assertContains(
+                       'consetetur sadipscing elitr',
+                       $edit->output->getText(),
+                       "output"
+               );
+
+               $this->assertTrue( $content->equals( $edit->newContent ), "newContent field" );
+               $this->assertTrue( $content->equals( $edit->pstContent ), "pstContent field" );
+               $this->assertSame( $edit->output, $edit->output, "output field" );
+               $this->assertSame( $edit->popts, $edit->popts, "popts field" );
+               $this->assertSame( null, $edit->revid, "revid field" );
+
+               // Re-using the prepared info if possible
+               $sameEdit = $page->prepareContentForEdit( $content, null, $user, null, false );
+               $this->assertEquals( $edit, $sameEdit, 'equivalent PreparedEdit' );
+               $this->assertSame( $edit->pstContent, $sameEdit->pstContent, 're-use output' );
+               $this->assertSame( $edit->output, $sameEdit->output, 're-use output' );
+
+               // Not re-using the same PreparedEdit if not possible
+               $rev = $page->getRevision();
+               $edit2 = $page->prepareContentForEdit( $content2, null, $user, null, false );
+               $this->assertNotEquals( $edit, $edit2 );
+               $this->assertContains( 'At vero eos', $edit2->pstContent->serialize(), "content" );
+
+               // Check pre-safe transform
+               $this->assertContains( '[[gubergren]]', $edit2->pstContent->serialize() );
+               $this->assertNotContains( '~~~~', $edit2->pstContent->serialize() );
+
+               $edit3 = $page->prepareContentForEdit( $content2, null, $sysop, null, false );
+               $this->assertNotEquals( $edit2, $edit3 );
+
+               // TODO: test with passing revision, then same without revision.
+       }
+
+       /**
         * @covers WikiPage::doEditUpdates
         */
+       public function testDoEditUpdates() {
+               $user = $this->getTestUser()->getUser();
+
+               // NOTE: if site stats get out of whack and drop below 0,
+               // that causes a DB error during tear-down. So bump the
+               // numbers high enough to not drop below 0.
+               $siteStatsUpdate = SiteStatsUpdate::factory(
+                       [ 'edits' => 1000, 'articles' => 1000, 'pages' => 1000 ]
+               );
+               $siteStatsUpdate->doUpdate();
+
+               $page = $this->createPage( __METHOD__, __METHOD__ );
+
+               $revision = new Revision(
+                       [
+                               'id' => 9989,
+                               'page' => $page->getId(),
+                               'title' => $page->getTitle(),
+                               'comment' => __METHOD__,
+                               'minor_edit' => true,
+                               'text' => __METHOD__ . ' [[|foo]][[bar]]', // PST turns [[|foo]] into [[foo]]
+                               'user' => $user->getId(),
+                               'user_text' => $user->getName(),
+                               'timestamp' => '20170707040404',
+                               'content_model' => CONTENT_MODEL_WIKITEXT,
+                               'content_format' => CONTENT_FORMAT_WIKITEXT,
+                       ]
+               );
+
+               $page->doEditUpdates( $revision, $user );
+
+               // TODO: test various options; needs temporary hooks
+
+               $dbr = wfGetDB( DB_REPLICA );
+               $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $page->getId() ] );
+               $n = $res->numRows();
+               $res->free();
+
+               $this->assertEquals( 1, $n, 'pagelinks should contain only one link if PST was not applied' );
+       }
+
+       /**
+        * @covers WikiPage::doEditContent
+        * @covers WikiPage::prepareContentForEdit
+        */
        public function testDoEditContent() {
                $this->setMwGlobals( 'wgPageCreationLog', true );
 
                $page = $this->newPage( __METHOD__ );
                $title = $page->getTitle();
 
+               $user1 = $this->getTestUser()->getUser();
+               // Use the confirmed group for user2 to make sure the user is different
+               $user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
+
                $content = ContentHandler::makeContent(
                        "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
                                . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
@@ -131,7 +248,18 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
                        CONTENT_MODEL_WIKITEXT
                );
 
-               $page->doEditContent( $content, "[[testing]] 1" );
+               $status = $page->doEditContent( $content, "[[testing]] 1", EDIT_NEW, false, $user1 );
+
+               $this->assertTrue( $status->isOK(), 'OK' );
+               $this->assertTrue( $status->value['new'], 'new' );
+               $this->assertNotNull( $status->value['revision'], 'revision' );
+               $this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
+               $this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
+               $this->assertTrue( $status->value['revision']->getContent()->equals( $content ), 'equals' );
+
+               $rev = $page->getRevision();
+               $this->assertNotNull( $rev->getRecentChange() );
+               $this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
 
                $id = $page->getId();
 
@@ -162,21 +290,47 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
                $retrieved = $page->getContent();
                $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
 
+               # ------------------------
+               $page = new WikiPage( $title );
+
+               // try null edit, with a different user
+               $status = $page->doEditContent( $content, 'This changes nothing', EDIT_UPDATE, false, $user2 );
+               $this->assertTrue( $status->isOK(), 'OK' );
+               $this->assertFalse( $status->value['new'], 'new' );
+               $this->assertNull( $status->value['revision'], 'revision' );
+               $this->assertNotNull( $page->getRevision() );
+               $this->assertTrue( $page->getRevision()->getContent()->equals( $content ), 'equals' );
+
                # ------------------------
                $content = ContentHandler::makeContent(
                        "At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
-                               . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.",
+                               . "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
                        $title,
                        CONTENT_MODEL_WIKITEXT
                );
 
-               $page->doEditContent( $content, "testing 2" );
+               $status = $page->doEditContent( $content, "testing 2", EDIT_UPDATE );
+               $this->assertTrue( $status->isOK(), 'OK' );
+               $this->assertFalse( $status->value['new'], 'new' );
+               $this->assertNotNull( $status->value['revision'], 'revision' );
+               $this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
+               $this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
+               $this->assertFalse(
+                       $status->value['revision']->getContent()->equals( $content ),
+                       'not equals (PST must substitute signature)'
+               );
+
+               $rev = $page->getRevision();
+               $this->assertNotNull( $rev->getRecentChange() );
+               $this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
 
                # ------------------------
                $page = new WikiPage( $title );
 
                $retrieved = $page->getContent();
-               $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
+               $newText = $retrieved->serialize();
+               $this->assertContains( '[[gubergren]]', $newText, 'New text must replace old text.' );
+               $this->assertNotContains( '~~~~', $newText, 'PST must substitute signature.' );
 
                # ------------------------
                $dbr = wfGetDB( DB_REPLICA );
@@ -1243,6 +1397,44 @@ more stuff
                $this->assertEquals( WikiPage::class, get_class( $page ) );
        }
 
+       /**
+        * @covers WikiPage::loadPageData
+        * @covers WikiPage::wasLoadedFrom
+        */
+       public function testLoadPageData() {
+               $title = Title::makeTitle( NS_MAIN, 'SomePage' );
+               $page = WikiPage::factory( $title );
+
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+               $page->loadPageData( IDBAccessObject::READ_NORMAL );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+               $page->loadPageData( IDBAccessObject::READ_LATEST );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+               $page->loadPageData( IDBAccessObject::READ_LOCKING );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+               $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+               $page->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+               $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+       }
+
        /**
         * @dataProvider provideCommentMigrationOnDeletion
         *
@@ -2099,4 +2291,89 @@ more stuff
                );
        }
 
+       /**
+        * @covers WikiPage::newPageUpdater
+        * @covers WikiPage::getDerivedDataUpdater
+        */
+       public function testNewPageUpdater() {
+               $user = $this->getTestUser()->getUser();
+               $page = $this->newPage( __METHOD__, __METHOD__ );
+
+               /** @var Content $content */
+               $content = $this->getMockBuilder( WikitextContent::class )
+                       ->setConstructorArgs( [ 'Hello World' ] )
+                       ->setMethods( [ 'getParserOutput' ] )
+                       ->getMock();
+               $content->expects( $this->once() )
+                       ->method( 'getParserOutput' )
+                       ->willReturn( new ParserOutput( 'HTML' ) );
+
+               $updater = $page->newPageUpdater( $user );
+               $updater->setContent( 'main', $content );
+               $revision = $updater->saveRevision(
+                       CommentStoreComment::newUnsavedComment( 'test' ),
+                       EDIT_NEW
+               );
+
+               $this->assertSame( $revision->getId(), $page->getLatest() );
+       }
+
+       /**
+        * @covers WikiPage::newPageUpdater
+        * @covers WikiPage::getDerivedDataUpdater
+        */
+       public function testGetDerivedDataUpdater() {
+               $admin = $this->getTestSysop()->getUser();
+
+               /** @var object $page */
+               $page = $this->createPage( __METHOD__, __METHOD__ );
+               $page = TestingAccessWrapper::newFromObject( $page );
+
+               $revision = $page->getRevision()->getRevisionRecord();
+               $user = $revision->getUser();
+
+               $slotsUpdate = new RevisionSlotsUpdate();
+               $slotsUpdate->modifyContent( 'main', new WikitextContent( 'Hello World' ) );
+
+               // get a virgin updater
+               $updater1 = $page->getDerivedDataUpdater( $user );
+               $this->assertFalse( $updater1->isUpdatePrepared() );
+
+               $updater1->prepareUpdate( $revision );
+
+               // Re-use updater with same revision or content
+               $this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, $revision ) );
+
+               $slotsUpdate = RevisionSlotsUpdate::newFromContent(
+                       [ 'main' => $revision->getContent( 'main' ) ]
+               );
+               $this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, null, $slotsUpdate ) );
+
+               // Don't re-use with different user
+               $updater2a = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
+               $updater2a->prepareContent( $admin, $slotsUpdate, false );
+
+               $updater2b = $page->getDerivedDataUpdater( $user, null, $slotsUpdate );
+               $updater2b->prepareContent( $user, $slotsUpdate, false );
+               $this->assertNotSame( $updater2a, $updater2b );
+
+               // Don't re-use with different content
+               $updater3 = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
+               $updater3->prepareUpdate( $revision );
+               $this->assertNotSame( $updater2b, $updater3 );
+
+               // Don't re-use if no context given
+               $updater4 = $page->getDerivedDataUpdater( $admin );
+               $updater4->prepareUpdate( $revision );
+               $this->assertNotSame( $updater3, $updater4 );
+
+               // Don't re-use if AGAIN no context given
+               $updater5 = $page->getDerivedDataUpdater( $admin );
+               $this->assertNotSame( $updater4, $updater5 );
+
+               // Don't re-use cached "virgin" unprepared updater
+               $updater6 = $page->getDerivedDataUpdater( $admin, $revision );
+               $this->assertNotSame( $updater5, $updater6 );
+       }
+
 }
index 3eb6abd..294bd80 100644 (file)
@@ -4,6 +4,7 @@ define( 'NS_UNITTEST', 5600 );
 define( 'NS_UNITTEST_TALK', 5601 );
 
 use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentityValue;
 use Wikimedia\TestingAccessWrapper;
 
 /**
@@ -1149,6 +1150,40 @@ class UserTest extends MediaWikiTestCase {
                }
        }
 
+       /**
+        * @covers User::newFromIdentity
+        */
+       public function testNewFromIdentity() {
+               // Registered user
+               $user = $this->getTestUser()->getUser();
+
+               $this->assertSame( $user, User::newFromIdentity( $user ) );
+
+               // ID only
+               $identity = new UserIdentityValue( $user->getId(), '', 0 );
+               $result = User::newFromIdentity( $identity );
+               $this->assertInstanceOf( User::class, $result );
+               $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+               $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+               $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+
+               // Name only
+               $identity = new UserIdentityValue( 0, $user->getName(), 0 );
+               $result = User::newFromIdentity( $identity );
+               $this->assertInstanceOf( User::class, $result );
+               $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+               $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+               $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+
+               // Actor only
+               $identity = new UserIdentityValue( 0, '', $user->getActorId() );
+               $result = User::newFromIdentity( $identity );
+               $this->assertInstanceOf( User::class, $result );
+               $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+               $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+               $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+       }
+
        /**
         * @covers User::getBlockedStatus
         * @covers User::getBlock