--- /dev/null
+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.
* @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 );
--- /dev/null
+<?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' );
+ }
+
+}
* 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 );
* @param SlotRecord $parentSlot
*/
public function inheritSlot( SlotRecord $parentSlot ) {
- $slot = SlotRecord::newInherited( $parentSlot );
- $this->setSlot( $slot );
+ $this->mSlots->inheritSlot( $parentSlot );
+ $this->resetAggregateValues();
}
/**
$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
*/
* 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 ) {
}
/**
- * 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
/**
* 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 ) {
--- /dev/null
+<?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 {
+
+}
--- /dev/null
+<?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;
+ }
+
+}
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
}
/**
- * 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 ) {
}
/**
- * 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.
*
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
* 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() {
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 );
+ }
+ }
+
}
* 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
* @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 = []
) {
* 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
* @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;
/**
* @param string $timestamp
- * @param Title &$title
- * @param User &$user
+ * @param Title $title
+ * @param User $user
* @param string $actionComment
* @param string $ip
* @param string $type
* @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;
/**
* @param string $timestamp
- * @param Title &$title
- * @param User &$user
+ * @param Title $title
+ * @param User $user
* @param string $actionComment
* @param string $ip
* @param string $type
* @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;
/**
* Represents information returned by WikiPage::prepareContentForEdit()
*
+ * @deprecated since 1.32, use DerivedPageDataUpdater instead.
+ *
* @since 1.30
*/
class PreparedEdit {
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.
*/
protected $mLinksUpdated = '19700101000000';
+ /**
+ * @var DerivedPageDataUpdater|null
+ */
+ private $derivedDataUpdater = null;
+
/**
* Constructor and clear the article
* @param Title $title Reference to a Title object.
}
}
+ /**
+ * @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
*
$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.
}
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 );
$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
*
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();
* @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 );
}
if ( $useParserCache ) {
- $parserOutput = MediaWikiServices::getInstance()->getParserCache()
+ $parserOutput = $this->getParserCache()
->get( $this, $parserOptions );
if ( $parserOutput !== false ) {
return $parserOutput;
* 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
*
/**
* 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
) {
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(
) {
$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.
/**
* 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
*/
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
* 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)
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();
}
/**
/**
* 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
* @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();
}
/**
* 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:
* - '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();
}
/**
// 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;
* @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();
* @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();
*
* @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(
return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() );
}
+
}
) {
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 ) ) {
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
*
--- /dev/null
+<?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())
+ }
+
+}
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;
$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' ) );
+ }
+
}
namespace MediaWiki\Tests\Storage;
+use Content;
use InvalidArgumentException;
use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionAccessException;
$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();
$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' ) );
+ }
+
}
--- /dev/null
+<?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
+ );
+ }
+
+}
}
/**
- * @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(
$slotsArray = [ $mainSlot, $auxSlot ];
$slots = $this->newRevisionSlots( $slotsArray );
- $this->assertEquals( [ 'main' => $mainSlot ], $slots->getTouchedSlots() );
+ $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() );
}
public function provideComputeSize() {
namespace MediaWiki\Tests\Storage;
+use Content;
+use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionSlots;
use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWiki\Storage\RevisionAccessException;
*
* @param RevisionSlots $newSlots
* @param RevisionSlots $parentSlots
- * @param $modified
- * @param $removed
+ * @param string[] $modified
+ * @param string[] $removed
*/
public function testNewFromRevisionSlots(
RevisionSlots $newSlots,
}
}
+ 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();
$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' ) );
+ }
+
}
$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' );
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, [] );
}
<?php
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WikiPage
+ */
abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
private $pagesToDelete;
*
* @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.",
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();
$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 );
$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
*
);
}
+ /**
+ * @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 );
+ }
+
}
define( 'NS_UNITTEST_TALK', 5601 );
use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentityValue;
use Wikimedia\TestingAccessWrapper;
/**
}
}
+ /**
+ * @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