From: Brad Jorsch Date: Thu, 20 Sep 2018 17:29:04 +0000 (-0400) Subject: Re-namespace RevisionStore and RevisionRecord classes X-Git-Tag: 1.34.0-rc.0~3843^2 X-Git-Url: http://git.cyclocoop.org/%7B%24www_url%7Dadmin/%22%24ccApp/ecrire/?a=commitdiff_plain;h=dff469a408dac341da27c97eaac85df946140e30;p=lhc%2Fweb%2Fwiklou.git Re-namespace RevisionStore and RevisionRecord classes During development a lot of classes were placed in MediaWiki\Storage\. The precedent set would mean that every class relating to something stored in a database table, plus all related value classes and such, would go into that namespace. Let's put them into MediaWiki\Revision\ instead. Then future classes related to the 'page' table can go into MediaWiki\Page\, future classes related to the 'user' table can go into MediaWiki\User\, and so on. Note I didn't move DerivedPageDataUpdater, PageUpdateException, PageUpdater, or RevisionSlotsUpdate in this patch. If these are kept long-term, they probably belong in MediaWiki\Page\ or MediaWiki\Edit\ instead. Bug: T204158 Change-Id: I16bea8927566a3c73c07e4f4afb3537e05aa04a5 --- diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 63d0894344..5b871e0ecb 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -482,6 +482,13 @@ because of Phabricator reports. deprecated. Use CommentStore::insert() instead. * Language::setCode is deprecated as public function. Use Language::factory to create a new Language object with a different language code. +* Several classes have been moved from the MediaWiki\Storage\ namespace to the + MediaWiki\Revision\ namespace. The old class names are aliased for + compatibility, but are deprecated. Classes are IncompleteRevisionException, + MutableRevisionRecord, MutableRevisionSlots, RevisionAccessException, + RevisionArchiveRecord, RevisionFactory, RevisionLookup, RevisionRecord, + RevisionSlots, RevisionStore, RevisionStoreRecord, SlotRecord, and + SuppressedDataException. === Other changes in 1.32 === * (T198811) The following tables have had their UNIQUE indexes turned into diff --git a/autoload.php b/autoload.php index ac47093776..6f26f35493 100644 --- a/autoload.php +++ b/autoload.php @@ -896,13 +896,23 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php', 'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php', 'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php', - 'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php', - 'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php', - 'MediaWiki\\Revision\\SlotRenderingProvider' => __DIR__ . '/includes/Revision/SlotRenderingProvider.php', 'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php', 'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php', 'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php', 'MediaWiki\\Special\\SpecialPageFactory' => __DIR__ . '/includes/specialpage/SpecialPageFactory.php', + 'MediaWiki\\Storage\\IncompleteRevisionException' => __DIR__ . '/includes/Revision/IncompleteRevisionException.php', + 'MediaWiki\\Storage\\MutableRevisionRecord' => __DIR__ . '/includes/Revision/MutableRevisionRecord.php', + 'MediaWiki\\Storage\\MutableRevisionSlots' => __DIR__ . '/includes/Revision/MutableRevisionSlots.php', + 'MediaWiki\\Storage\\RevisionAccessException' => __DIR__ . '/includes/Revision/RevisionAccessException.php', + 'MediaWiki\\Storage\\RevisionArchiveRecord' => __DIR__ . '/includes/Revision/RevisionArchiveRecord.php', + 'MediaWiki\\Storage\\RevisionFactory' => __DIR__ . '/includes/Revision/RevisionFactory.php', + 'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Revision/RevisionLookup.php', + 'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Revision/RevisionRecord.php', + 'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Revision/RevisionSlots.php', + 'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Revision/RevisionStore.php', + 'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Revision/RevisionStoreRecord.php', + 'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Revision/SlotRecord.php', + 'MediaWiki\\Storage\\SuppressedDataException' => __DIR__ . '/includes/Revision/SuppressedDataException.php', 'MediaWiki\\User\\UserIdentity' => __DIR__ . '/includes/user/UserIdentity.php', 'MediaWiki\\User\\UserIdentityValue' => __DIR__ . '/includes/user/UserIdentityValue.php', 'MediaWiki\\Widget\\CheckMatrixWidget' => __DIR__ . '/includes/widget/CheckMatrixWidget.php', diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 5482f6a4e3..e4e59da983 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -134,6 +134,7 @@ class AutoLoader { 'MediaWiki\\EditPage\\' => __DIR__ . '/editpage/', 'MediaWiki\\Linker\\' => __DIR__ . '/linker/', 'MediaWiki\\Preferences\\' => __DIR__ . '/preferences/', + 'MediaWiki\\Revision\\' => __DIR__ . '/Revision/', 'MediaWiki\\Services\\' => __DIR__ . '/services/', 'MediaWiki\\Session\\' => __DIR__ . '/session/', 'MediaWiki\\Shell\\' => __DIR__ . '/shell/', diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index b236ca1da1..0acd55a822 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -22,11 +22,11 @@ use MediaWiki\Storage\BlobStore; use MediaWiki\Storage\BlobStoreFactory; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\NameTableStoreFactory; -use MediaWiki\Storage\RevisionFactory; -use MediaWiki\Storage\RevisionLookup; -use MediaWiki\Storage\RevisionStore; +use MediaWiki\Revision\RevisionFactory; +use MediaWiki\Revision\RevisionLookup; +use MediaWiki\Revision\RevisionStore; use OldRevisionImporter; -use MediaWiki\Storage\RevisionStoreFactory; +use MediaWiki\Revision\RevisionStoreFactory; use UploadRevisionImporter; use Wikimedia\Rdbms\LBFactory; use LinkCache; diff --git a/includes/Revision.php b/includes/Revision.php index e45c6dce0d..5a6afd8dad 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -20,14 +20,14 @@ * @file */ -use MediaWiki\Storage\MutableRevisionRecord; -use MediaWiki\Storage\RevisionAccessException; -use MediaWiki\Storage\RevisionFactory; -use MediaWiki\Storage\RevisionLookup; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\RevisionStore; -use MediaWiki\Storage\RevisionStoreRecord; -use MediaWiki\Storage\SlotRecord; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\RevisionAccessException; +use MediaWiki\Revision\RevisionFactory; +use MediaWiki\Revision\RevisionLookup; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\RevisionStore; +use MediaWiki\Revision\RevisionStoreRecord; +use MediaWiki\Revision\SlotRecord; use MediaWiki\Storage\SqlBlobStore; use Wikimedia\Rdbms\IDatabase; use MediaWiki\Linker\LinkTarget; diff --git a/includes/Revision/IncompleteRevisionException.php b/includes/Revision/IncompleteRevisionException.php new file mode 100644 index 0000000000..808b0d22d1 --- /dev/null +++ b/includes/Revision/IncompleteRevisionException.php @@ -0,0 +1,39 @@ +getPageAsLinkTarget() ); + $rev = new MutableRevisionRecord( $title, $parent->getWikiId() ); + + foreach ( $parent->getSlotRoles() as $role ) { + $slot = $parent->getSlot( $role, self::RAW ); + $rev->inheritSlot( $slot ); + } + + $rev->setPageId( $parent->getPageId() ); + $rev->setParentId( $parent->getId() ); + + return $rev; + } + + /** + * @note Avoid calling this constructor directly. Use the appropriate methods + * in RevisionStore instead. + * + * @param Title $title The title of the page this Revision is associated with. + * @param bool|string $wikiId the wiki ID of the site this Revision belongs to, + * or false for the local site. + * + * @throws MWException + */ + function __construct( Title $title, $wikiId = false ) { + $slots = new MutableRevisionSlots(); + + parent::__construct( $title, $slots, $wikiId ); + + $this->mSlots = $slots; // redundant, but nice for static analysis + } + + /** + * @param int $parentId + */ + public function setParentId( $parentId ) { + Assert::parameterType( 'integer', $parentId, '$parentId' ); + + $this->mParentId = $parentId; + } + + /** + * Sets the given slot. If a slot with the same role is already present in the revision, + * it is replaced. + * + * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a + * SlotRecord from another revision should use inheritSlot(). Calling code that has access to + * a Content object can use setContent(). + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param SlotRecord $slot + */ + public function setSlot( SlotRecord $slot ) { + if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) { + throw new InvalidArgumentException( + 'The given slot must be an unsaved, unattached one. ' + . 'This slot is already attached to revision ' . $slot->getRevision() . '. ' + . 'Use inheritSlot() instead to preserve a slot from a previous revision.' + ); + } + + $this->mSlots->setSlot( $slot ); + $this->resetAggregateValues(); + } + + /** + * "Inherits" the given slot's content. + * + * If a slot with the same role is already present in the revision, it is replaced. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @param SlotRecord $parentSlot + */ + public function inheritSlot( SlotRecord $parentSlot ) { + $this->mSlots->inheritSlot( $parentSlot ); + $this->resetAggregateValues(); + } + + /** + * Sets the content for the slot with the given role. + * + * If a slot with the same role is already present in the revision, it is replaced. + * Calling code that has access to a SlotRecord can use inheritSlot() instead. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param string $role + * @param Content $content + */ + public function setContent( $role, Content $content ) { + $this->mSlots->setContent( $role, $content ); + $this->resetAggregateValues(); + } + + /** + * Removes the slot with the given role from this revision. + * This effectively ends the "stream" with that role on the revision's page. + * Future revisions will no longer inherit this slot, unless it is added back explicitly. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param string $role + */ + public function removeSlot( $role ) { + $this->mSlots->removeSlot( $role ); + $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 + */ + public function setComment( CommentStoreComment $comment ) { + $this->mComment = $comment; + } + + /** + * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash. + * + * @note This should only be used if the calling code is sure that the given hash is correct + * for the revision's content, and there is no chance of the content being manipulated + * later. When in doubt, this method should not be called. + * + * @param string $sha1 SHA1 hash as a base36 string. + */ + public function setSha1( $sha1 ) { + Assert::parameterType( 'string', $sha1, '$sha1' ); + + $this->mSha1 = $sha1; + } + + /** + * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size. + * + * @note This should only be used if the calling code is sure that the given size is correct + * for the revision's content, and there is no chance of the content being manipulated + * later. When in doubt, this method should not be called. + * + * @param int $size nominal size in bogo-bytes + */ + public function setSize( $size ) { + Assert::parameterType( 'integer', $size, '$size' ); + + $this->mSize = $size; + } + + /** + * @param int $visibility + */ + public function setVisibility( $visibility ) { + Assert::parameterType( 'integer', $visibility, '$visibility' ); + + $this->mDeleted = $visibility; + } + + /** + * @param string $timestamp A timestamp understood by wfTimestamp + */ + public function setTimestamp( $timestamp ) { + Assert::parameterType( 'string', $timestamp, '$timestamp' ); + + $this->mTimestamp = wfTimestamp( TS_MW, $timestamp ); + } + + /** + * @param bool $minorEdit + */ + public function setMinorEdit( $minorEdit ) { + Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' ); + + $this->mMinorEdit = $minorEdit; + } + + /** + * Set the revision ID. + * + * MCR migration note: this replaces Revision::setId() + * + * @warning Use this with care, especially when preparing a revision for insertion + * into the database! The revision ID should only be fixed in special cases + * like preserving the original ID when restoring a revision. + * + * @param int $id + */ + public function setId( $id ) { + Assert::parameterType( 'integer', $id, '$id' ); + + $this->mId = $id; + } + + /** + * Sets the user identity associated with the revision + * + * @param UserIdentity $user + */ + public function setUser( UserIdentity $user ) { + $this->mUser = $user; + } + + /** + * @param int $pageId + */ + public function setPageId( $pageId ) { + Assert::parameterType( 'integer', $pageId, '$pageId' ); + + if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) { + throw new InvalidArgumentException( + 'The given Title does not belong to page ID ' . $this->mPageId + ); + } + + $this->mPageId = $pageId; + } + + /** + * Returns the nominal size of this revision. + * + * MCR migration note: this replaces Revision::getSize + * + * @return int The nominal size, may be computed on the fly if not yet known. + */ + public function getSize() { + // If not known, re-calculate and remember. Will be reset when slots change. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * Returns the base36 sha1 of this revision. + * + * MCR migration note: this replaces Revision::getSha1 + * + * @return string The revision hash, may be computed on the fly if not yet known. + */ + public function getSha1() { + // If not known, re-calculate and remember. Will be reset when slots change. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * Returns the slots defined for this revision as a MutableRevisionSlots instance, + * which can be modified to defined the slots for this revision. + * + * @return MutableRevisionSlots + */ + public function getSlots() { + // Overwritten just guarantee the more narrow return type. + return parent::getSlots(); + } + + /** + * Invalidate cached aggregate values such as hash and size. + */ + private function resetAggregateValues() { + $this->mSize = null; + $this->mSha1 = null; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( MutableRevisionRecord::class, 'MediaWiki\Storage\MutableRevisionRecord' ); diff --git a/includes/Revision/MutableRevisionSlots.php b/includes/Revision/MutableRevisionSlots.php new file mode 100644 index 0000000000..cd4bc2e6be --- /dev/null +++ b/includes/Revision/MutableRevisionSlots.php @@ -0,0 +1,116 @@ +getRole(); + $inherited[$role] = SlotRecord::newInherited( $slot ); + } + + return new MutableRevisionSlots( $inherited ); + } + + /** + * @param SlotRecord[] $slots An array of SlotRecords. + */ + public function __construct( array $slots = [] ) { + parent::__construct( $slots ); + } + + /** + * Sets the given slot. + * If a slot with the same role is already present, it is replaced. + * + * @param SlotRecord $slot + */ + public function setSlot( SlotRecord $slot ) { + if ( !is_array( $this->slots ) ) { + $this->getSlots(); // initialize $this->slots + } + + $role = $slot->getRole(); + $this->slots[$role] = $slot; + } + + /** + * Sets the given slot to an inherited version of $slot. + * If a slot with the same role is already present, it is replaced. + * + * @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 + */ + public function setContent( $role, Content $content ) { + $slot = SlotRecord::newUnsaved( $role, $content ); + $this->setSlot( $slot ); + } + + /** + * Remove the slot for the given role, discontinue the corresponding stream. + * + * @param string $role + */ + public function removeSlot( $role ) { + if ( !is_array( $this->slots ) ) { + $this->getSlots(); // initialize $this->slots + } + + unset( $this->slots[$role] ); + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( MutableRevisionSlots::class, 'MediaWiki\Storage\MutableRevisionSlots' ); diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php index fa16c61304..9cb20e0c01 100644 --- a/includes/Revision/RenderedRevision.php +++ b/includes/Revision/RenderedRevision.php @@ -24,8 +24,6 @@ namespace MediaWiki\Revision; use InvalidArgumentException; use LogicException; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\SuppressedDataException; use ParserOptions; use ParserOutput; use Psr\Log\LoggerInterface; diff --git a/includes/Revision/RevisionAccessException.php b/includes/Revision/RevisionAccessException.php new file mode 100644 index 0000000000..290991ecf6 --- /dev/null +++ b/includes/Revision/RevisionAccessException.php @@ -0,0 +1,41 @@ +ar_timestamp ); + Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); + + $this->mArchiveId = intval( $row->ar_id ); + + // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases, + // notably when a partially restored page has been moved, and a new page has been created + // with the same title. Archive rows for that title will then have the wrong page id. + $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID(); + + // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null + // indicates that the parent revision is unknown. As per MW 1.31, the database schema + // allows ar_parent_id to be NULL. + $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null; + $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null; + $this->mComment = $comment; + $this->mUser = $user; + $this->mTimestamp = $timestamp; + $this->mMinorEdit = boolval( $row->ar_minor_edit ); + $this->mDeleted = intval( $row->ar_deleted ); + $this->mSize = isset( $row->ar_len ) ? intval( $row->ar_len ) : null; + $this->mSha1 = !empty( $row->ar_sha1 ) ? $row->ar_sha1 : null; + } + + /** + * Get archive row ID + * + * @return int + */ + public function getArchiveId() { + return $this->mArchiveId; + } + + /** + * @return int|null The revision id, or null if the original revision ID + * was not recorded in the archive table. + */ + public function getId() { + // overwritten just to refine the contract specification. + return parent::getId(); + } + + /** + * @throws RevisionAccessException if the size was unknown and could not be calculated. + * @return int The nominal revision size, never null. May be computed on the fly. + */ + public function getSize() { + // If length is null, calculate and remember it (potentially SLOW!). + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * @throws RevisionAccessException if the hash was unknown and could not be calculated. + * @return string The revision hash, never null. May be computed on the fly. + */ + public function getSha1() { + // If hash is null, calculate it and remember (potentially SLOW!) + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * @param int $audience + * @param User|null $user + * + * @return UserIdentity The identity of the revision author, null if access is forbidden. + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getUser( $audience, $user ); + } + + /** + * @param int $audience + * @param User|null $user + * + * @return CommentStoreComment The revision comment, null if access is forbidden. + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getComment( $audience, $user ); + } + + /** + * @return string never null + */ + public function getTimestamp() { + // overwritten just to add a guarantee to the contract + return parent::getTimestamp(); + } + + /** + * @see RevisionStore::isComplete + * + * @return bool always true. + */ + public function isReadyForInsertion() { + return true; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionArchiveRecord::class, 'MediaWiki\Storage\RevisionArchiveRecord' ); diff --git a/includes/Revision/RevisionFactory.php b/includes/Revision/RevisionFactory.php new file mode 100644 index 0000000000..44f1350706 --- /dev/null +++ b/includes/Revision/RevisionFactory.php @@ -0,0 +1,101 @@ +ar_user, etc. + * + * @return RevisionRecord + */ + public function newRevisionFromArchiveRow( + $row, + $queryFlags = 0, + Title $title = null, + array $overrides = [] + ); + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionFactory::class, 'MediaWiki\Storage\RevisionFactory' ); diff --git a/includes/Revision/RevisionLookup.php b/includes/Revision/RevisionLookup.php new file mode 100644 index 0000000000..db6c7c30b4 --- /dev/null +++ b/includes/Revision/RevisionLookup.php @@ -0,0 +1,127 @@ +mTitle = $title; + $this->mSlots = $slots; + $this->mWiki = $wikiId; + + // XXX: this is a sensible default, but we may not have a Title object here in the future. + $this->mPageId = $title->getArticleID(); + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * @param RevisionRecord $rec + * + * @return bool True if this RevisionRecord is known to have same content as $rec. + * False if the content is different (or not known to be the same). + */ + public function hasSameContent( RevisionRecord $rec ) { + if ( $rec === $this ) { + return true; + } + + if ( $this->getId() !== null && $this->getId() === $rec->getId() ) { + return true; + } + + // check size before hash, since size is quicker to compute + if ( $this->getSize() !== $rec->getSize() ) { + return false; + } + + // instead of checking the hash, we could also check the content addresses of all slots. + + if ( $this->getSha1() === $rec->getSha1() ) { + return true; + } + + return false; + } + + /** + * Returns the Content of the given slot of this revision. + * Call getSlotNames() to get a list of available slots. + * + * Note that for mutable Content objects, each call to this method will return a + * fresh clone. + * + * MCR migration note: this replaces Revision::getContent + * + * @param string $role The role name of the desired slot + * @param int $audience + * @param User|null $user + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return Content|null The content of the given slot, or null if access is forbidden. + */ + public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) { + // XXX: throwing an exception would be nicer, but would a further + // departure from the signature of Revision::getContent(), and thus + // more complex and error prone refactoring. + if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { + return null; + } + + $content = $this->getSlot( $role, $audience, $user )->getContent(); + return $content->copy(); + } + + /** + * Returns meta-data for the given slot. + * + * @param string $role The role name of the desired slot + * @param int $audience + * @param User|null $user + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return SlotRecord The slot meta-data. If access to the slot content is forbidden, + * calling getContent() on the SlotRecord will throw an exception. + */ + public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) { + $slot = $this->mSlots->getSlot( $role ); + + if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { + return SlotRecord::newWithSuppressedContent( $slot ); + } + + return $slot; + } + + /** + * Returns whether the given slot is defined in this revision. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function hasSlot( $role ) { + return $this->mSlots->hasSlot( $role ); + } + + /** + * Returns the slot names (roles) of all slots present in this revision. + * getContent() will succeed only for the names returned by this method. + * + * @return string[] + */ + public function getSlotRoles() { + 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 + * in the database). + * + * MCR migration note: this replaces Revision::getId + * + * @return int|null + */ + public function getId() { + return $this->mId; + } + + /** + * Get parent revision ID (the original previous page revision). + * If there is no parent revision, this returns 0. + * If the parent revision is undefined or unknown, this returns null. + * + * @note As of MW 1.31, the database schema allows the parent ID to be + * NULL to indicate that it is unknown. + * + * MCR migration note: this replaces Revision::getParentId + * + * @return int|null + */ + public function getParentId() { + return $this->mParentId; + } + + /** + * Returns the nominal size of this revision, in bogo-bytes. + * May be calculated on the fly if not known, which may in the worst + * case may involve loading all content. + * + * MCR migration note: this replaces Revision::getSize + * + * @throws RevisionAccessException if the size was unknown and could not be calculated. + * @return int + */ + abstract public function getSize(); + + /** + * Returns the base36 sha1 of this revision. This hash is derived from the + * hashes of all slots associated with the revision. + * May be calculated on the fly if not known, which may in the worst + * case may involve loading all content. + * + * MCR migration note: this replaces Revision::getSha1 + * + * @throws RevisionAccessException if the hash was unknown and could not be calculated. + * @return string + */ + abstract public function getSha1(); + + /** + * Get the page ID. If the page does not yet exist, the page ID is 0. + * + * MCR migration note: this replaces Revision::getPage + * + * @return int + */ + public function getPageId() { + return $this->mPageId; + } + + /** + * Get the ID of the wiki this revision belongs to. + * + * @return string|false The wiki's logical name, of false to indicate the local wiki. + */ + public function getWikiId() { + return $this->mWiki; + } + + /** + * Returns the title of the page this revision is associated with as a LinkTarget object. + * + * MCR migration note: this replaces Revision::getTitle + * + * @return LinkTarget + */ + public function getPageAsLinkTarget() { + return $this->mTitle; + } + + /** + * Fetch revision's author's user identity, if it's available to the specified audience. + * If the specified audience does not have access to it, null will be + * returned. Depending on the concrete subclass, null may also be returned if the user is + * not yet specified. + * + * MCR migration note: this replaces Revision::getUser + * + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the ID regardless of permissions + * @param User|null $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return UserIdentity|null + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) { + return null; + } else { + return $this->mUser; + } + } + + /** + * Fetch revision comment, if it's available to the specified audience. + * If the specified audience does not have access to the comment, + * this will return null. Depending on the concrete subclass, null may also be returned + * if the comment is not yet specified. + * + * MCR migration note: this replaces Revision::getComment + * + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the text regardless of permissions + * @param User|null $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * + * @return CommentStoreComment|null + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) { + return null; + } else { + return $this->mComment; + } + } + + /** + * MCR migration note: this replaces Revision::isMinor + * + * @return bool + */ + public function isMinor() { + return (bool)$this->mMinorEdit; + } + + /** + * MCR migration note: this replaces Revision::isDeleted + * + * @param int $field One of DELETED_* bitfield constants + * + * @return bool + */ + public function isDeleted( $field ) { + return ( $this->getVisibility() & $field ) == $field; + } + + /** + * Get the deletion bitfield of the revision + * + * MCR migration note: this replaces Revision::getVisibility + * + * @return int + */ + public function getVisibility() { + return (int)$this->mDeleted; + } + + /** + * MCR migration note: this replaces Revision::getTimestamp. + * + * May return null if the timestamp was not specified. + * + * @return string|null + */ + public function getTimestamp() { + return $this->mTimestamp; + } + + /** + * Check that the given audience has access to the given field. + * + * MCR migration note: this corresponds to Revision::userCan + * + * @param int $field One of self::DELETED_TEXT, + * self::DELETED_COMMENT, + * self::DELETED_USER + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the text regardless of permissions + * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER, + * ignored otherwise. + * + * @return bool + */ + public function audienceCan( $field, $audience, User $user = null ) { + if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) { + return false; + } elseif ( $audience == self::FOR_THIS_USER ) { + if ( !$user ) { + throw new InvalidArgumentException( + 'A User object must be given when checking FOR_THIS_USER audience.' + ); + } + + if ( !$this->userCan( $field, $user ) ) { + return false; + } + } + + return true; + } + + /** + * Determine if the current user is allowed to view a particular + * field of this revision, if it's marked as deleted. + * + * MCR migration note: this corresponds to Revision::userCan + * + * @param int $field One of self::DELETED_TEXT, + * self::DELETED_COMMENT, + * self::DELETED_USER + * @param User $user User object to check + * @return bool + */ + protected function userCan( $field, User $user ) { + // TODO: use callback for permission checks, so we don't need to know a Title object! + return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle ); + } + + /** + * Determine if the current user is allowed to view a particular + * field of this revision, if it's marked as deleted. This is used + * by various classes to avoid duplication. + * + * MCR migration note: this replaces Revision::userCanBitfield + * + * @param int $bitfield Current field + * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE, + * self::DELETED_COMMENT = File::DELETED_COMMENT, + * self::DELETED_USER = File::DELETED_USER + * @param User $user User object to check + * @param Title|null $title A Title object to check for per-page restrictions on, + * instead of just plain userrights + * @return bool + */ + public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) { + if ( $bitfield & $field ) { // aspect is deleted + if ( $bitfield & self::DELETED_RESTRICTED ) { + $permissions = [ 'suppressrevision', 'viewsuppressed' ]; + } elseif ( $field & self::DELETED_TEXT ) { + $permissions = [ 'deletedtext' ]; + } else { + $permissions = [ 'deletedhistory' ]; + } + $permissionlist = implode( ', ', $permissions ); + if ( $title === null ) { + wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" ); + return $user->isAllowedAny( ...$permissions ); + } else { + $text = $title->getPrefixedText(); + wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" ); + foreach ( $permissions as $perm ) { + if ( $title->userCan( $perm, $user ) ) { + return true; + } + } + return false; + } + } else { + return true; + } + } + + /** + * Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all + * information needed to save it to the database. This should trivially be true for + * RevisionRecords loaded from the database. + * + * Note that this may return true even if getId() or getPage() return null or 0, since these + * are generally assigned while the revision is saved to the database, and may not be available + * before. + * + * @return bool + */ + public function isReadyForInsertion() { + // NOTE: don't check getSize() and getSha1(), since that may cause the full content to + // be loaded in order to calculate the values. Just assume these methods will not return + // null if mSlots is not empty. + + // NOTE: getId() and getPageId() may return null before a revision is saved, so don't + //check them. + + return $this->getTimestamp() !== null + && $this->getComment( self::RAW ) !== null + && $this->getUser( self::RAW ) !== null + && $this->mSlots->getSlotRoles() !== []; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionRecord::class, 'MediaWiki\Storage\RevisionRecord' ); diff --git a/includes/Revision/RevisionRenderer.php b/includes/Revision/RevisionRenderer.php index c937376555..377477bf94 100644 --- a/includes/Revision/RevisionRenderer.php +++ b/includes/Revision/RevisionRenderer.php @@ -24,8 +24,6 @@ namespace MediaWiki\Revision; use Html; use InvalidArgumentException; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\SlotRecord; use ParserOptions; use ParserOutput; use Psr\Log\LoggerInterface; diff --git a/includes/Revision/RevisionSlots.php b/includes/Revision/RevisionSlots.php new file mode 100644 index 0000000000..661137a8dc --- /dev/null +++ b/includes/Revision/RevisionSlots.php @@ -0,0 +1,319 @@ +slots = $slots; + } else { + $this->setSlotsInternal( $slots ); + } + } + + /** + * @param SlotRecord[] $slots + */ + private function setSlotsInternal( array $slots ) { + Assert::parameterElementType( SlotRecord::class, $slots, '$slots' ); + + $this->slots = []; + + // re-key the slot array + foreach ( $slots as $slot ) { + $role = $slot->getRole(); + $this->slots[$role] = $slot; + } + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * Returns the Content of the given slot. + * Call getSlotNames() to get a list of available slots. + * + * Note that for mutable Content objects, each call to this method will return a + * fresh clone. + * + * @param string $role The role name of the desired slot + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return Content + */ + public function getContent( $role ) { + // Return a copy to be safe. Immutable content objects return $this from copy(). + return $this->getSlot( $role )->getContent()->copy(); + } + + /** + * Returns the SlotRecord of the given slot. + * Call getSlotNames() to get a list of available slots. + * + * @param string $role The role name of the desired slot + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return SlotRecord + */ + public function getSlot( $role ) { + $slots = $this->getSlots(); + + if ( isset( $slots[$role] ) ) { + return $slots[$role]; + } else { + throw new RevisionAccessException( 'No such slot: ' . $role ); + } + } + + /** + * Returns whether the given slot is set. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function hasSlot( $role ) { + $slots = $this->getSlots(); + + return isset( $slots[$role] ); + } + + /** + * Returns the slot names (roles) of all slots present in this revision. + * getContent() will succeed only for the names returned by this method. + * + * @return string[] + */ + public function getSlotRoles() { + $slots = $this->getSlots(); + return array_keys( $slots ); + } + + /** + * Computes the total nominal size of the revision's slots, in bogo-bytes. + * + * @warning This is potentially expensive! It may cause all slot's content to be loaded + * and deserialized. + * + * @return int + */ + public function computeSize() { + return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) { + return $accu + $slot->getSize(); + }, 0 ); + } + + /** + * Returns an associative array that maps role names to SlotRecords. Each SlotRecord + * represents the content meta-data of a slot, together they define the content of + * a revision. + * + * @note This may cause the content meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] revision slot/content rows, keyed by slot role name. + */ + public function getSlots() { + if ( is_callable( $this->slots ) ) { + $slots = call_user_func( $this->slots ); + + Assert::postcondition( + is_array( $slots ), + 'Slots info callback should return an array of objects' + ); + + $this->setSlotsInternal( $slots ); + } + + return $this->slots; + } + + /** + * Computes the combined hash of the revisions's slots. + * + * @note For backwards compatibility, the combined hash of a single slot + * is that slot's hash. For consistency, the combined hash of an empty set of slots + * is the hash of the empty string. + * + * @warning This is potentially expensive! It may cause all slot's content to be loaded + * and deserialized, then re-serialized and hashed. + * + * @return string + */ + public function computeSha1() { + $slots = $this->getSlots(); + ksort( $slots ); + + if ( empty( $slots ) ) { + return SlotRecord::base36Sha1( '' ); + } + + return array_reduce( $slots, function ( $accu, SlotRecord $slot ) { + return $accu === null + ? $slot->getSha1() + : SlotRecord::base36Sha1( $accu . $slot->getSha1() ); + }, null ); + } + + /** + * 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 getOriginalSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return !$slot->isInherited(); + } + ); + } + + /** + * 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 SlotRecord[] + */ + public function getInheritedSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return $slot->isInherited(); + } + ); + } + + /** + * Checks whether the other RevisionSlots instance has the same content + * as this instance. Note that this does not mean that the slots have to be the same: + * they could for instance belong to different revisions. + * + * @param RevisionSlots $other + * + * @return bool + */ + public function hasSameContent( RevisionSlots $other ) { + if ( $other === $this ) { + return true; + } + + $aSlots = $this->getSlots(); + $bSlots = $other->getSlots(); + + ksort( $aSlots ); + ksort( $bSlots ); + + if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) { + return false; + } + + foreach ( $aSlots as $role => $s ) { + $t = $bSlots[$role]; + + if ( !$s->hasSameContent( $t ) ) { + return false; + } + } + + return true; + } + + /** + * Find roles for which the $other RevisionSlots object has different content + * as this RevisionSlots object, including any roles that are present in one + * but not the other. + * + * @param RevisionSlots $other + * + * @return string[] a list of slot roles that are different. + */ + public function getRolesWithDifferentContent( RevisionSlots $other ) { + if ( $other === $this ) { + return []; + } + + $aSlots = $this->getSlots(); + $bSlots = $other->getSlots(); + + ksort( $aSlots ); + ksort( $bSlots ); + + $different = array_keys( array_merge( + array_diff_key( $aSlots, $bSlots ), + array_diff_key( $bSlots, $aSlots ) + ) ); + + /** @var SlotRecord[] $common */ + $common = array_intersect_key( $aSlots, $bSlots ); + + foreach ( $common as $role => $s ) { + $t = $bSlots[$role]; + + if ( !$s->hasSameContent( $t ) ) { + $different[] = $role; + } + } + + return $different; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionSlots::class, 'MediaWiki\Storage\RevisionSlots' ); diff --git a/includes/Revision/RevisionStore.php b/includes/Revision/RevisionStore.php new file mode 100644 index 0000000000..bef566d5f5 --- /dev/null +++ b/includes/Revision/RevisionStore.php @@ -0,0 +1,2791 @@ +loadBalancer = $loadBalancer; + $this->blobStore = $blobStore; + $this->cache = $cache; + $this->commentStore = $commentStore; + $this->contentModelStore = $contentModelStore; + $this->slotRoleStore = $slotRoleStore; + $this->mcrMigrationStage = $mcrMigrationStage; + $this->actorMigration = $actorMigration; + $this->wikiId = $wikiId; + $this->logger = new NullLogger(); + } + + /** + * @param int $flags A combination of SCHEMA_COMPAT_XXX flags. + * @return bool True if all the given flags were set in the $mcrMigrationStage + * parameter passed to the constructor. + */ + private function hasMcrSchemaFlags( $flags ) { + return ( $this->mcrMigrationStage & $flags ) === $flags; + } + + /** + * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading + * and still reading from the old DB schema. + * + * @throws RevisionAccessException + */ + private function assertCrossWikiContentLoadingIsSafe() { + if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + throw new RevisionAccessException( + "Cross-wiki content loading is not supported by the pre-MCR schema" + ); + } + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @return bool Whether the store is read-only + */ + public function isReadOnly() { + return $this->blobStore->isReadOnly(); + } + + /** + * @return bool + */ + public function getContentHandlerUseDB() { + return $this->contentHandlerUseDB; + } + + /** + * @see $wgContentHandlerUseDB + * @param bool $contentHandlerUseDB + * @throws MWException + */ + public function setContentHandlerUseDB( $contentHandlerUseDB ) { + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) + || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) + ) { + if ( !$contentHandlerUseDB ) { + throw new MWException( + 'Content model must be stored in the database for multi content revision migration.' + ); + } + } + $this->contentHandlerUseDB = $contentHandlerUseDB; + } + + /** + * @return ILoadBalancer + */ + private function getDBLoadBalancer() { + return $this->loadBalancer; + } + + /** + * @param int $mode DB_MASTER or DB_REPLICA + * + * @return IDatabase + */ + private function getDBConnection( $mode ) { + $lb = $this->getDBLoadBalancer(); + return $lb->getConnection( $mode, [], $this->wikiId ); + } + + /** + * @param int $queryFlags a bit field composed of READ_XXX flags + * + * @return DBConnRef + */ + private function getDBConnectionRefForQueryFlags( $queryFlags ) { + list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); + return $this->getDBConnectionRef( $mode ); + } + + /** + * @param IDatabase $connection + */ + private function releaseDBConnection( IDatabase $connection ) { + $lb = $this->getDBLoadBalancer(); + $lb->reuseConnection( $connection ); + } + + /** + * @param int $mode DB_MASTER or DB_REPLICA + * + * @return DBConnRef + */ + private function getDBConnectionRef( $mode ) { + $lb = $this->getDBLoadBalancer(); + return $lb->getConnectionRef( $mode, [], $this->wikiId ); + } + + /** + * Determines the page Title based on the available information. + * + * MCR migration note: this corresponds to Revision::getTitle + * + * @note this method should be private, external use should be avoided! + * + * @param int|null $pageId + * @param int|null $revId + * @param int $queryFlags + * + * @return Title + * @throws RevisionAccessException + */ + public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) { + if ( !$pageId && !$revId ) { + throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' ); + } + + // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title + // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method + if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) { + $queryFlags = self::READ_NORMAL; + } + + $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->wikiId === false ); + list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); + $titleFlags = ( $dbMode == DB_MASTER ? Title::GAID_FOR_UPDATE : 0 ); + + // Loading by ID is best, but Title::newFromID does not support that for foreign IDs. + if ( $canUseTitleNewFromId ) { + // TODO: better foreign title handling (introduce TitleFactory) + $title = Title::newFromID( $pageId, $titleFlags ); + if ( $title ) { + return $title; + } + } + + // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. + $canUseRevId = ( $revId !== null && $revId > 0 ); + + if ( $canUseRevId ) { + $dbr = $this->getDBConnectionRef( $dbMode ); + // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that + $row = $dbr->selectRow( + [ 'revision', 'page' ], + [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ], + [ 'rev_id' => $revId ], + __METHOD__, + $dbOptions, + [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ] + ); + if ( $row ) { + // TODO: better foreign title handling (introduce TitleFactory) + return Title::newFromRow( $row ); + } + } + + // If we still don't have a title, fallback to master if that wasn't already happening. + if ( $dbMode !== DB_MASTER ) { + $title = $this->getTitle( $pageId, $revId, self::READ_LATEST ); + if ( $title ) { + $this->logger->info( + __METHOD__ . ' fell back to READ_LATEST and got a Title.', + [ 'trace' => wfBacktrace() ] + ); + return $title; + } + } + + throw new RevisionAccessException( + "Could not determine title for page ID $pageId and revision ID $revId" + ); + } + + /** + * @param mixed $value + * @param string $name + * + * @throws IncompleteRevisionException if $value is null + * @return mixed $value, if $value is not null + */ + private function failOnNull( $value, $name ) { + if ( $value === null ) { + throw new IncompleteRevisionException( + "$name must not be " . var_export( $value, true ) . "!" + ); + } + + return $value; + } + + /** + * @param mixed $value + * @param string $name + * + * @throws IncompleteRevisionException if $value is empty + * @return mixed $value, if $value is not null + */ + private function failOnEmpty( $value, $name ) { + if ( $value === null || $value === 0 || $value === '' ) { + throw new IncompleteRevisionException( + "$name must not be " . var_export( $value, true ) . "!" + ); + } + + return $value; + } + + /** + * Insert a new revision into the database, returning the new revision record + * on success and dies horribly on failure. + * + * MCR migration note: this replaces Revision::insertOn + * + * @param RevisionRecord $rev + * @param IDatabase $dbw (master connection) + * + * @throws InvalidArgumentException + * @return RevisionRecord the new revision record. + */ + public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { + // TODO: pass in a DBTransactionContext instead of a database connection. + $this->checkDatabaseWikiId( $dbw ); + + $slotRoles = $rev->getSlotRoles(); + + // Make sure the main slot is always provided throughout migration + if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) { + throw new InvalidArgumentException( + 'main slot must be provided' + ); + } + + // If we are not writing into the new schema, we can't support extra slots. + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) + && $slotRoles !== [ SlotRecord::MAIN ] + ) { + throw new InvalidArgumentException( + 'Only the main slot is supported when not writing to the MCR enabled schema!' + ); + } + + // As long as we are not reading from the new schema, we don't want to write extra slots. + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) + && $slotRoles !== [ SlotRecord::MAIN ] + ) { + throw new InvalidArgumentException( + 'Only the main slot is supported when not reading from the MCR enabled schema!' + ); + } + + // Checks + $this->failOnNull( $rev->getSize(), 'size field' ); + $this->failOnEmpty( $rev->getSha1(), 'sha1 field' ); + $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); + $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); + $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); + $this->failOnNull( $user->getId(), 'user field' ); + $this->failOnEmpty( $user->getName(), 'user_text field' ); + + if ( !$rev->isReadyForInsertion() ) { + // This is here for future-proofing. At the time this check being added, it + // was redundant to the individual checks above. + throw new IncompleteRevisionException( 'Revision is incomplete' ); + } + + // TODO: we shouldn't need an actual Title here. + $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); + $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early + + $parentId = $rev->getParentId() === null + ? $this->getPreviousRevisionId( $dbw, $rev ) + : $rev->getParentId(); + + /** @var RevisionRecord $rev */ + $rev = $dbw->doAtomicSection( + __METHOD__, + function ( IDatabase $dbw, $fname ) use ( + $rev, + $user, + $comment, + $title, + $pageId, + $parentId + ) { + return $this->insertRevisionInternal( + $rev, + $dbw, + $user, + $comment, + $title, + $pageId, + $parentId + ); + } + ); + + // sanity checks + Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' ); + Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' ); + Assert::postcondition( + $rev->getComment( RevisionRecord::RAW ) !== null, + 'revision must have a comment' + ); + Assert::postcondition( + $rev->getUser( RevisionRecord::RAW ) !== null, + 'revision must have a user' + ); + + // Trigger exception if the main slot is missing. + // Technically, this could go away after MCR migration: while + // calling code may require a main slot to exist, RevisionStore + // really should not know or care about that requirement. + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); + + foreach ( $slotRoles as $role ) { + $slot = $rev->getSlot( $role, RevisionRecord::RAW ); + Assert::postcondition( + $slot->getContent() !== null, + $role . ' slot must have content' + ); + Assert::postcondition( + $slot->hasRevision(), + $role . ' slot must have a revision associated' + ); + } + + Hooks::run( 'RevisionRecordInserted', [ $rev ] ); + + // TODO: deprecate in 1.32! + $legacyRevision = new Revision( $rev ); + Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] ); + + return $rev; + } + + private function insertRevisionInternal( + RevisionRecord $rev, + IDatabase $dbw, + User $user, + CommentStoreComment $comment, + Title $title, + $pageId, + $parentId + ) { + $slotRoles = $rev->getSlotRoles(); + + $revisionRow = $this->insertRevisionRowOn( + $dbw, + $rev, + $title, + $parentId + ); + + $revisionId = $revisionRow['rev_id']; + + $blobHints = [ + BlobStore::PAGE_HINT => $pageId, + BlobStore::REVISION_HINT => $revisionId, + BlobStore::PARENT_HINT => $parentId, + ]; + + $newSlots = []; + foreach ( $slotRoles as $role ) { + $slot = $rev->getSlot( $role, RevisionRecord::RAW ); + + // If the SlotRecord already has a revision ID set, this means it already exists + // in the database, and should already belong to the current revision. + // However, a slot may already have a revision, but no content ID, if the slot + // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD + // mode, and the respective archive row was not yet migrated to the new schema. + // In that case, a new slot row (and content row) must be inserted even during + // undeletion. + if ( $slot->hasRevision() && $slot->hasContentId() ) { + // TODO: properly abort transaction if the assertion fails! + Assert::parameter( + $slot->getRevision() === $revisionId, + 'slot role ' . $slot->getRole(), + 'Existing slot should belong to revision ' + . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!' + ); + + // Slot exists, nothing to do, move along. + // This happens when restoring archived revisions. + + $newSlots[$role] = $slot; + + // Write the main slot's text ID to the revision table for backwards compatibility + if ( $slot->getRole() === SlotRecord::MAIN + && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) + ) { + $blobAddress = $slot->getAddress(); + $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress ); + } + } else { + $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints ); + } + } + + $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId ); + + $rev = new RevisionStoreRecord( + $title, + $user, + $comment, + (object)$revisionRow, + new RevisionSlots( $newSlots ), + $this->wikiId + ); + + return $rev; + } + + /** + * @param IDatabase $dbw + * @param int $revisionId + * @param string &$blobAddress (may change!) + * + * @return int the text row id + */ + private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) { + $textId = $this->blobStore->getTextIdFromAddress( $blobAddress ); + if ( !$textId ) { + throw new LogicException( + 'Blob address not supported in 1.29 database schema: ' . $blobAddress + ); + } + + // getTextIdFromAddress() is free to insert something into the text table, so $textId + // may be a new value, not anything already contained in $blobAddress. + $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId ); + + $dbw->update( + 'revision', + [ 'rev_text_id' => $textId ], + [ 'rev_id' => $revisionId ], + __METHOD__ + ); + + return $textId; + } + + /** + * @param IDatabase $dbw + * @param int $revisionId + * @param SlotRecord $protoSlot + * @param Title $title + * @param array $blobHints See the BlobStore::XXX_HINT constants + * @return SlotRecord + */ + private function insertSlotOn( + IDatabase $dbw, + $revisionId, + SlotRecord $protoSlot, + Title $title, + array $blobHints = [] + ) { + if ( $protoSlot->hasAddress() ) { + $blobAddress = $protoSlot->getAddress(); + } else { + $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints ); + } + + $contentId = null; + + // Write the main slot's text ID to the revision table for backwards compatibility + if ( $protoSlot->getRole() === SlotRecord::MAIN + && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) + ) { + // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten + // with the real content ID below. + $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress ); + $contentId = $this->emulateContentId( $textId ); + } + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { + if ( $protoSlot->hasContentId() ) { + $contentId = $protoSlot->getContentId(); + } else { + $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress ); + } + + $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId ); + } + + $savedSlot = SlotRecord::newSaved( + $revisionId, + $contentId, + $blobAddress, + $protoSlot + ); + + return $savedSlot; + } + + /** + * Insert IP revision into ip_changes for use when querying for a range. + * @param IDatabase $dbw + * @param User $user + * @param RevisionRecord $rev + * @param int $revisionId + */ + private function insertIpChangesRow( + IDatabase $dbw, + User $user, + RevisionRecord $rev, + $revisionId + ) { + if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) { + $ipcRow = [ + 'ipc_rev_id' => $revisionId, + 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), + 'ipc_hex' => IP::toHex( $user->getName() ), + ]; + $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); + } + } + + /** + * @param IDatabase $dbw + * @param RevisionRecord $rev + * @param Title $title + * @param int $parentId + * + * @return array a revision table row + * + * @throws MWException + * @throws MWUnknownContentModelException + */ + private function insertRevisionRowOn( + IDatabase $dbw, + RevisionRecord $rev, + Title $title, + $parentId + ) { + $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId ); + + list( $commentFields, $commentCallback ) = + $this->commentStore->insertWithTempTable( + $dbw, + 'rev_comment', + $rev->getComment( RevisionRecord::RAW ) + ); + $revisionRow += $commentFields; + + list( $actorFields, $actorCallback ) = + $this->actorMigration->getInsertValuesWithTempTable( + $dbw, + 'rev_user', + $rev->getUser( RevisionRecord::RAW ) + ); + $revisionRow += $actorFields; + + $dbw->insert( 'revision', $revisionRow, __METHOD__ ); + + if ( !isset( $revisionRow['rev_id'] ) ) { + // only if auto-increment was used + $revisionRow['rev_id'] = intval( $dbw->insertId() ); + + if ( $dbw->getType() === 'mysql' ) { + // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the + // auto-increment value to disk, so on server restart it might reuse IDs from deleted + // revisions. We can fix that with an insert with an explicit rev_id value, if necessary. + + $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) ); + $table = 'archive'; + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { + $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) ); + if ( $maxRevId2 >= $maxRevId ) { + $maxRevId = $maxRevId2; + $table = 'slots'; + } + } + + if ( $maxRevId >= $revisionRow['rev_id'] ) { + $this->logger->debug( + '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.' + . ' Trying to fix it.', + [ + 'revid' => $revisionRow['rev_id'], + 'table' => $table, + 'maxrevid' => $maxRevId, + ] + ); + + if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) { + throw new MWException( 'Failed to get database lock for T202032' ); + } + $fname = __METHOD__; + $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) { + $dbw->unlock( 'fix-for-T202032', $fname ); + } ); + + $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ ); + + // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction + // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing + // inserts too, though, at least on MariaDB 10.1.29. + // + // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent + // transactions in this code path thanks to the row lock from the original ->insert() above. + // + // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning + // that's for non-MySQL DBs. + $row1 = $dbw->query( + $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE' + )->fetchObject(); + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { + $row2 = $dbw->query( + $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ ) + . ' FOR UPDATE' + )->fetchObject(); + } else { + $row2 = null; + } + $maxRevId = max( + $maxRevId, + $row1 ? intval( $row1->v ) : 0, + $row2 ? intval( $row2->v ) : 0 + ); + + // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent + // transactions will throw a duplicate key error here. It doesn't seem worth trying + // to avoid that. + $revisionRow['rev_id'] = $maxRevId + 1; + $dbw->insert( 'revision', $revisionRow, __METHOD__ ); + } + } + } + + $commentCallback( $revisionRow['rev_id'] ); + $actorCallback( $revisionRow['rev_id'], $revisionRow ); + + return $revisionRow; + } + + /** + * @param IDatabase $dbw + * @param RevisionRecord $rev + * @param Title $title + * @param int $parentId + * + * @return array [ 0 => array $revisionRow, 1 => callable ] + * @throws MWException + * @throws MWUnknownContentModelException + */ + private function getBaseRevisionRow( + IDatabase $dbw, + RevisionRecord $rev, + Title $title, + $parentId + ) { + // Record the edit in revisions + $revisionRow = [ + 'rev_page' => $rev->getPageId(), + 'rev_parent_id' => $parentId, + 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, + 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), + 'rev_deleted' => $rev->getVisibility(), + 'rev_len' => $rev->getSize(), + 'rev_sha1' => $rev->getSha1(), + ]; + + if ( $rev->getId() !== null ) { + // Needed to restore revisions with their original ID + $revisionRow['rev_id'] = $rev->getId(); + } + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) { + // In non MCR mode this IF section will relate to the main slot + $mainSlot = $rev->getSlot( SlotRecord::MAIN ); + $model = $mainSlot->getModel(); + $format = $mainSlot->getFormat(); + + // MCR migration note: rev_content_model and rev_content_format will go away + if ( $this->contentHandlerUseDB ) { + $this->assertCrossWikiContentLoadingIsSafe(); + + $defaultModel = ContentHandler::getDefaultModelFor( $title ); + $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat(); + + $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model; + $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format; + } + } + + return $revisionRow; + } + + /** + * @param SlotRecord $slot + * @param Title $title + * @param array $blobHints See the BlobStore::XXX_HINT constants + * + * @throws MWException + * @return string the blob address + */ + private function storeContentBlob( + SlotRecord $slot, + Title $title, + array $blobHints = [] + ) { + $content = $slot->getContent(); + $format = $content->getDefaultFormat(); + $model = $content->getModel(); + + $this->checkContent( $content, $title ); + + return $this->blobStore->storeBlob( + $content->serialize( $format ), + // These hints "leak" some information from the higher abstraction layer to + // low level storage to allow for optimization. + array_merge( + $blobHints, + [ + BlobStore::DESIGNATION_HINT => 'page-content', + BlobStore::ROLE_HINT => $slot->getRole(), + BlobStore::SHA1_HINT => $slot->getSha1(), + BlobStore::MODEL_HINT => $model, + BlobStore::FORMAT_HINT => $format, + ] + ) + ); + } + + /** + * @param SlotRecord $slot + * @param IDatabase $dbw + * @param int $revisionId + * @param int $contentId + */ + private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) { + $slotRow = [ + 'slot_revision_id' => $revisionId, + 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ), + 'slot_content_id' => $contentId, + // If the slot has a specific origin use that ID, otherwise use the ID of the revision + // that we just inserted. + 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId, + ]; + $dbw->insert( 'slots', $slotRow, __METHOD__ ); + } + + /** + * @param SlotRecord $slot + * @param IDatabase $dbw + * @param string $blobAddress + * @return int content row ID + */ + private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) { + $contentRow = [ + 'content_size' => $slot->getSize(), + 'content_sha1' => $slot->getSha1(), + 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ), + 'content_address' => $blobAddress, + ]; + $dbw->insert( 'content', $contentRow, __METHOD__ ); + return intval( $dbw->insertId() ); + } + + /** + * MCR migration note: this corresponds to Revision::checkContentModel + * + * @param Content $content + * @param Title $title + * + * @throws MWException + * @throws MWUnknownContentModelException + */ + private function checkContent( Content $content, Title $title ) { + // Note: may return null for revisions that have not yet been inserted + + $model = $content->getModel(); + $format = $content->getDefaultFormat(); + $handler = $content->getContentHandler(); + + $name = "$title"; + + if ( !$handler->isSupportedFormat( $format ) ) { + throw new MWException( "Can't use format $format with content model $model on $name" ); + } + + if ( !$this->contentHandlerUseDB ) { + // if $wgContentHandlerUseDB is not set, + // all revisions must use the default content model and format. + + $this->assertCrossWikiContentLoadingIsSafe(); + + $defaultModel = ContentHandler::getDefaultModelFor( $title ); + $defaultHandler = ContentHandler::getForModelID( $defaultModel ); + $defaultFormat = $defaultHandler->getDefaultFormat(); + + if ( $model != $defaultModel ) { + throw new MWException( "Can't save non-default content model with " + . "\$wgContentHandlerUseDB disabled: model is $model, " + . "default for $name is $defaultModel" + ); + } + + if ( $format != $defaultFormat ) { + throw new MWException( "Can't use non-default content format with " + . "\$wgContentHandlerUseDB disabled: format is $format, " + . "default for $name is $defaultFormat" + ); + } + } + + if ( !$content->isValid() ) { + throw new MWException( + "New content for $name is not valid! Content model is $model" + ); + } + } + + /** + * Create a new null-revision for insertion into a page's + * history. This will not re-save the text, but simply refer + * to the text from the previous version. + * + * Such revisions can for instance identify page rename + * operations and other such meta-modifications. + * + * @note This method grabs a FOR UPDATE lock on the relevant row of the page table, + * to prevent a new revision from being inserted before the null revision has been written + * to the database. + * + * MCR migration note: this replaces Revision::newNullRevision + * + * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that + * (or go away). + * + * @param IDatabase $dbw used for obtaining the lock on the page table row + * @param Title $title Title of the page to read from + * @param CommentStoreComment $comment RevisionRecord's summary + * @param bool $minor Whether the revision should be considered as minor + * @param User $user The user to attribute the revision to + * + * @return RevisionRecord|null RevisionRecord or null on error + */ + public function newNullRevision( + IDatabase $dbw, + Title $title, + CommentStoreComment $comment, + $minor, + User $user + ) { + $this->checkDatabaseWikiId( $dbw ); + + $pageId = $title->getArticleID(); + + // T51581: Lock the page table row to ensure no other process + // is adding a revision to the page at the same time. + // Avoid locking extra tables, compare T191892. + $pageLatest = $dbw->selectField( + 'page', + 'page_latest', + [ 'page_id' => $pageId ], + __METHOD__, + [ 'FOR UPDATE' ] + ); + + if ( !$pageLatest ) { + return null; + } + + // Fetch the actual revision row from master, without locking all extra tables. + $oldRevision = $this->loadRevisionFromConds( + $dbw, + [ 'rev_id' => intval( $pageLatest ) ], + self::READ_LATEST, + $title + ); + + if ( !$oldRevision ) { + $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId."; + $this->logger->error( + $msg, + [ 'exception' => new RuntimeException( $msg ) ] + ); + return null; + } + + // Construct the new revision + $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing. + $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision ); + + $newRevision->setComment( $comment ); + $newRevision->setUser( $user ); + $newRevision->setTimestamp( $timestamp ); + $newRevision->setMinorEdit( $minor ); + + return $newRevision; + } + + /** + * MCR migration note: this replaces Revision::isUnpatrolled + * + * @todo This is overly specific, so move or kill this method. + * + * @param RevisionRecord $rev + * + * @return int Rcid of the unpatrolled row, zero if there isn't one + */ + public function getRcIdIfUnpatrolled( RevisionRecord $rev ) { + $rc = $this->getRecentChange( $rev ); + if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) { + return $rc->getAttribute( 'rc_id' ); + } else { + return 0; + } + } + + /** + * Get the RC object belonging to the current revision, if there's one + * + * MCR migration note: this replaces Revision::getRecentChange + * + * @todo move this somewhere else? + * + * @param RevisionRecord $rev + * @param int $flags (optional) $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * + * @return null|RecentChange + */ + public function getRecentChange( RevisionRecord $rev, $flags = 0 ) { + list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); + $db = $this->getDBConnection( $dbType ); + + $userIdentity = $rev->getUser( RevisionRecord::RAW ); + + if ( !$userIdentity ) { + // If the revision has no user identity, chances are it never went + // into the database, and doesn't have an RC entry. + return null; + } + + // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that! + $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false ); + $rc = RecentChange::newFromConds( + [ + $actorWhere['conds'], + 'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ), + 'rc_this_oldid' => $rev->getId() + ], + __METHOD__, + $dbType + ); + + $this->releaseDBConnection( $db ); + + // XXX: cache this locally? Glue it to the RevisionRecord? + return $rc; + } + + /** + * Maps fields of the archive row to corresponding revision rows. + * + * @param object $archiveRow + * + * @return object a revision row object, corresponding to $archiveRow. + */ + private static function mapArchiveFields( $archiveRow ) { + $fieldMap = [ + // keep with ar prefix: + 'ar_id' => 'ar_id', + + // not the same suffix: + 'ar_page_id' => 'rev_page', + 'ar_rev_id' => 'rev_id', + + // same suffix: + 'ar_text_id' => 'rev_text_id', + 'ar_timestamp' => 'rev_timestamp', + 'ar_user_text' => 'rev_user_text', + 'ar_user' => 'rev_user', + 'ar_actor' => 'rev_actor', + 'ar_minor_edit' => 'rev_minor_edit', + 'ar_deleted' => 'rev_deleted', + 'ar_len' => 'rev_len', + 'ar_parent_id' => 'rev_parent_id', + 'ar_sha1' => 'rev_sha1', + 'ar_comment' => 'rev_comment', + 'ar_comment_cid' => 'rev_comment_cid', + 'ar_comment_id' => 'rev_comment_id', + 'ar_comment_text' => 'rev_comment_text', + 'ar_comment_data' => 'rev_comment_data', + 'ar_comment_old' => 'rev_comment_old', + 'ar_content_format' => 'rev_content_format', + 'ar_content_model' => 'rev_content_model', + ]; + + $revRow = new stdClass(); + foreach ( $fieldMap as $arKey => $revKey ) { + if ( property_exists( $archiveRow, $arKey ) ) { + $revRow->$revKey = $archiveRow->$arKey; + } + } + + return $revRow; + } + + /** + * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema. + * + * @param object|array $row Either a database row or an array + * @param int $queryFlags for callbacks + * @param Title $title + * + * @return SlotRecord The main slot, extracted from the MW 1.29 style row. + * @throws MWException + */ + private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) { + $mainSlotRow = new stdClass(); + $mainSlotRow->role_name = SlotRecord::MAIN; + $mainSlotRow->model_name = null; + $mainSlotRow->slot_revision_id = null; + $mainSlotRow->slot_content_id = null; + $mainSlotRow->content_address = null; + + $content = null; + $blobData = null; + $blobFlags = null; + + if ( is_object( $row ) ) { + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) { + // Don't emulate from a row when using the new schema. + // Emulating from an array is still OK. + throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' ); + } + + // archive row + if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) { + $row = $this->mapArchiveFields( $row ); + } + + if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) { + $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId( + $row->rev_text_id + ); + } + + // This is used by null-revisions + $mainSlotRow->slot_origin = isset( $row->slot_origin ) + ? intval( $row->slot_origin ) + : null; + + if ( isset( $row->old_text ) ) { + // this happens when the text-table gets joined directly, in the pre-1.30 schema + $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null; + // Check against selects that might have not included old_flags + if ( !property_exists( $row, 'old_flags' ) ) { + throw new InvalidArgumentException( 'old_flags was not set in $row' ); + } + $blobFlags = $row->old_flags ?? ''; + } + + $mainSlotRow->slot_revision_id = intval( $row->rev_id ); + + $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; + $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null; + $mainSlotRow->model_name = isset( $row->rev_content_model ) + ? strval( $row->rev_content_model ) + : null; + // XXX: in the future, we'll probably always use the default format, and drop content_format + $mainSlotRow->format_name = isset( $row->rev_content_format ) + ? strval( $row->rev_content_format ) + : null; + + if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) { + // Overwritten below for SCHEMA_COMPAT_WRITE_NEW + $mainSlotRow->slot_content_id + = $this->emulateContentId( intval( $row->rev_text_id ) ); + } + } elseif ( is_array( $row ) ) { + $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null; + + $mainSlotRow->slot_origin = isset( $row['slot_origin'] ) + ? intval( $row['slot_origin'] ) + : null; + $mainSlotRow->content_address = isset( $row['text_id'] ) + ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) ) + : null; + $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null; + $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; + + $mainSlotRow->model_name = isset( $row['content_model'] ) + ? strval( $row['content_model'] ) : null; // XXX: must be a string! + // XXX: in the future, we'll probably always use the default format, and drop content_format + $mainSlotRow->format_name = isset( $row['content_format'] ) + ? strval( $row['content_format'] ) : null; + $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null; + // XXX: If the flags field is not set then $blobFlags should be null so that no + // decoding will happen. An empty string will result in default decodings. + $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null; + + // if we have a Content object, override mText and mContentModel + if ( !empty( $row['content'] ) ) { + if ( !( $row['content'] instanceof Content ) ) { + throw new MWException( 'content field must contain a Content object.' ); + } + + /** @var Content $content */ + $content = $row['content']; + $handler = $content->getContentHandler(); + + $mainSlotRow->model_name = $content->getModel(); + + // XXX: in the future, we'll probably always use the default format. + if ( $mainSlotRow->format_name === null ) { + $mainSlotRow->format_name = $handler->getDefaultFormat(); + } + } + + if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) { + // Overwritten below for SCHEMA_COMPAT_WRITE_NEW + $mainSlotRow->slot_content_id + = $this->emulateContentId( intval( $row['text_id'] ) ); + } + } else { + throw new MWException( 'Revision constructor passed invalid row format.' ); + } + + // With the old schema, the content changes with every revision, + // except for null-revisions. + if ( !isset( $mainSlotRow->slot_origin ) ) { + $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id; + } + + if ( $mainSlotRow->model_name === null ) { + $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) { + $this->assertCrossWikiContentLoadingIsSafe(); + + // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget! + // TODO: MCR: deprecate $title->getModel(). + return ContentHandler::getDefaultModelFor( $title ); + }; + } + + if ( !$content ) { + // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address + // is missing, but "empty revisions" with no content are used in some edge cases. + + $content = function ( SlotRecord $slot ) + use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow ) + { + return $this->loadSlotContent( + $slot, + $blobData, + $blobFlags, + $mainSlotRow->format_name, + $queryFlags + ); + }; + } + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { + // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing + // the inherited slot to have the same content_id as the original slot. In that case, + // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot. + $mainSlotRow->slot_content_id = + function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) { + $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); + return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN ); + }; + } + + return new SlotRecord( $mainSlotRow, $content ); + } + + /** + * Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode, + * based on the revision's text ID (rev_text_id or ar_text_id, respectively). + * Note that in SCHEMA_COMPAT_WRITE_BOTH, a callback to findSlotContentId() should be used + * instead, since in that mode, some revision rows may already have a real content ID, + * while other's don't - and for the ones that don't, we should indicate that it + * is missing and cause SlotRecords::hasContentId() to return false. + * + * @param int $textId + * @return int The emulated content ID + */ + private function emulateContentId( $textId ) { + // Return a negative number to ensure the ID is distinct from any real content IDs + // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW + // mode. + return -$textId; + } + + /** + * Loads a Content object based on a slot row. + * + * This method does not call $slot->getContent(), and may be used as a callback + * called by $slot->getContent(). + * + * MCR migration note: this roughly corresponds to Revision::getContentInternal + * + * @param SlotRecord $slot The SlotRecord to load content for + * @param string|null $blobData The content blob, in the form indicated by $blobFlags + * @param string|null $blobFlags Flags indicating how $blobData needs to be processed. + * Use null if no processing should happen. That is in constrast to the empty string, + * which causes the blob to be decoded according to the configured legacy encoding. + * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded + * @param int $queryFlags + * + * @throws RevisionAccessException + * @return Content + */ + private function loadSlotContent( + SlotRecord $slot, + $blobData = null, + $blobFlags = null, + $blobFormat = null, + $queryFlags = 0 + ) { + if ( $blobData !== null ) { + Assert::parameterType( 'string', $blobData, '$blobData' ); + Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' ); + + $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null; + + if ( $blobFlags === null ) { + // No blob flags, so use the blob verbatim. + $data = $blobData; + } else { + $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey ); + if ( $data === false ) { + throw new RevisionAccessException( + "Failed to expand blob data using flags $blobFlags (key: $cacheKey)" + ); + } + } + + } else { + $address = $slot->getAddress(); + try { + $data = $this->blobStore->getBlob( $address, $queryFlags ); + } catch ( BlobAccessException $e ) { + throw new RevisionAccessException( + "Failed to load data blob from $address: " . $e->getMessage(), 0, $e + ); + } + } + + // Unserialize content + $handler = ContentHandler::getForModelID( $slot->getModel() ); + + $content = $handler->unserializeContent( $data, $blobFormat ); + return $content; + } + + /** + * Load a page revision from a given revision ID number. + * Returns null if no such revision can be found. + * + * MCR migration note: this replaces Revision::newFromId + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param int $id + * @param int $flags (optional) + * @return RevisionRecord|null + */ + public function getRevisionById( $id, $flags = 0 ) { + return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given link target. If not attached + * to that link target, will return null. + * + * MCR migration note: this replaces Revision::newFromTitle + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param LinkTarget $linkTarget + * @param int $revId (optional) + * @param int $flags Bitfield (optional) + * @return RevisionRecord|null + */ + public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) { + $conds = [ + 'page_namespace' => $linkTarget->getNamespace(), + 'page_title' => $linkTarget->getDBkey() + ]; + if ( $revId ) { + // Use the specified revision ID. + // Note that we use newRevisionFromConds here because we want to retry + // and fall back to master if the page is not found on a replica. + // Since the caller supplied a revision ID, we are pretty sure the revision is + // supposed to exist, so we should try hard to find it. + $conds['rev_id'] = $revId; + return $this->newRevisionFromConds( $conds, $flags ); + } else { + // Use a join to get the latest revision. + // Note that we don't use newRevisionFromConds here because we don't want to retry + // and fall back to master. The assumption is that we only want to force the fallback + // if we are quite sure the revision exists because the caller supplied a revision ID. + // If the page isn't found at all on a replica, it probably simply does not exist. + $db = $this->getDBConnectionRefForQueryFlags( $flags ); + + $conds[] = 'rev_id=page_latest'; + $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); + + return $rev; + } + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page ID. + * Returns null if no such revision can be found. + * + * MCR migration note: this replaces Revision::newFromPageId + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20) + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param int $pageId + * @param int $revId (optional) + * @param int $flags Bitfield (optional) + * @return RevisionRecord|null + */ + public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) { + $conds = [ 'page_id' => $pageId ]; + if ( $revId ) { + // Use the specified revision ID. + // Note that we use newRevisionFromConds here because we want to retry + // and fall back to master if the page is not found on a replica. + // Since the caller supplied a revision ID, we are pretty sure the revision is + // supposed to exist, so we should try hard to find it. + $conds['rev_id'] = $revId; + return $this->newRevisionFromConds( $conds, $flags ); + } else { + // Use a join to get the latest revision. + // Note that we don't use newRevisionFromConds here because we don't want to retry + // and fall back to master. The assumption is that we only want to force the fallback + // if we are quite sure the revision exists because the caller supplied a revision ID. + // If the page isn't found at all on a replica, it probably simply does not exist. + $db = $this->getDBConnectionRefForQueryFlags( $flags ); + + $conds[] = 'rev_id=page_latest'; + $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); + + return $rev; + } + } + + /** + * Load the revision for the given title with the given timestamp. + * WARNING: Timestamps may in some circumstances not be unique, + * so this isn't the best key to use. + * + * MCR migration note: this replaces Revision::loadFromTimestamp + * + * @param Title $title + * @param string $timestamp + * @return RevisionRecord|null + */ + public function getRevisionByTimestamp( $title, $timestamp ) { + $db = $this->getDBConnection( DB_REPLICA ); + return $this->newRevisionFromConds( + [ + 'rev_timestamp' => $db->timestamp( $timestamp ), + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * @param int $revId The revision to load slots for. + * @param int $queryFlags + * + * @return SlotRecord[] + */ + private function loadSlotRecords( $revId, $queryFlags ) { + $revQuery = self::getSlotsQueryInfo( [ 'content' ] ); + + list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); + $db = $this->getDBConnectionRef( $dbMode ); + + $res = $db->select( + $revQuery['tables'], + $revQuery['fields'], + [ + 'slot_revision_id' => $revId, + ], + __METHOD__, + $dbOptions, + $revQuery['joins'] + ); + + $slots = []; + + foreach ( $res as $row ) { + // resolve role names and model names from in-memory cache, instead of joining. + $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id ); + $row->model_name = $this->contentModelStore->getName( (int)$row->content_model ); + + $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags, $row ) { + return $this->loadSlotContent( $slot, null, null, null, $queryFlags ); + }; + + $slots[$row->role_name] = new SlotRecord( $row, $contentCallback ); + } + + if ( !isset( $slots[SlotRecord::MAIN] ) ) { + throw new RevisionAccessException( + 'Main slot of revision ' . $revId . ' not found in database!' + ); + }; + + return $slots; + } + + /** + * Factory method for RevisionSlots. + * + * @note If other code has a need to construct RevisionSlots objects, this should be made + * public, since RevisionSlots instances should not be constructed directly. + * + * @param int $revId + * @param object $revisionRow + * @param int $queryFlags + * @param Title $title + * + * @return RevisionSlots + * @throws MWException + */ + private function newRevisionSlots( + $revId, + $revisionRow, + $queryFlags, + Title $title + ) { + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) { + $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title ); + $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] ); + } else { + // XXX: do we need the same kind of caching here + // that getKnownCurrentRevision uses (if $revId == page_latest?) + + $slots = new RevisionSlots( function () use( $revId, $queryFlags ) { + return $this->loadSlotRecords( $revId, $queryFlags ); + } ); + } + + return $slots; + } + + /** + * Make a fake revision object from an archive table row. This is queried + * for permissions or even inserted (as in Special:Undelete) + * + * MCR migration note: this replaces Revision::newFromArchiveRow + * + * @param object $row + * @param int $queryFlags + * @param Title|null $title + * @param array $overrides associative array with fields of $row to override. This may be + * used e.g. to force the parent revision ID or page ID. Keys in the array are fields + * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to + * override ar_parent_id. + * + * @return RevisionRecord + * @throws MWException + */ + public function newRevisionFromArchiveRow( + $row, + $queryFlags = 0, + Title $title = null, + array $overrides = [] + ) { + Assert::parameterType( 'object', $row, '$row' ); + + // check second argument, since Revision::newFromArchiveRow had $overrides in that spot. + Assert::parameterType( 'integer', $queryFlags, '$queryFlags' ); + + if ( !$title && isset( $overrides['title'] ) ) { + if ( !( $overrides['title'] instanceof Title ) ) { + throw new MWException( 'title field override must contain a Title object.' ); + } + + $title = $overrides['title']; + } + + if ( !isset( $title ) ) { + if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) { + $title = Title::makeTitle( $row->ar_namespace, $row->ar_title ); + } else { + throw new InvalidArgumentException( + 'A Title or ar_namespace and ar_title must be given' + ); + } + } + + foreach ( $overrides as $key => $value ) { + $field = "ar_$key"; + $row->$field = $value; + } + + try { + $user = User::newFromAnyId( + $row->ar_user ?? null, + $row->ar_user_text ?? null, + $row->ar_actor ?? null + ); + } catch ( InvalidArgumentException $ex ) { + wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() ); + $user = new UserIdentityValue( 0, 'Unknown user', 0 ); + } + + $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); + // Legacy because $row may have come from self::selectFields() + $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true ); + + $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $queryFlags, $title ); + + return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); + } + + /** + * @see RevisionFactory::newRevisionFromRow + * + * MCR migration note: this replaces Revision::newFromRow + * + * @param object $row + * @param int $queryFlags + * @param Title|null $title + * + * @return RevisionRecord + */ + public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) { + Assert::parameterType( 'object', $row, '$row' ); + + if ( !$title ) { + $pageId = $row->rev_page ?? 0; // XXX: also check page_id? + $revId = $row->rev_id ?? 0; + + $title = $this->getTitle( $pageId, $revId, $queryFlags ); + } + + if ( !isset( $row->page_latest ) ) { + $row->page_latest = $title->getLatestRevID(); + if ( $row->page_latest === 0 && $title->exists() ) { + wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() ); + } + } + + try { + $user = User::newFromAnyId( + $row->rev_user ?? null, + $row->rev_user_text ?? null, + $row->rev_actor ?? null + ); + } catch ( InvalidArgumentException $ex ) { + wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() ); + $user = new UserIdentityValue( 0, 'Unknown user', 0 ); + } + + $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); + // Legacy because $row may have come from self::selectFields() + $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true ); + + $slots = $this->newRevisionSlots( $row->rev_id, $row, $queryFlags, $title ); + + return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); + } + + /** + * Constructs a new MutableRevisionRecord based on the given associative array following + * the MW1.29 convention for the Revision constructor. + * + * MCR migration note: this replaces Revision::newFromRow + * + * @param array $fields + * @param int $queryFlags + * @param Title|null $title + * + * @return MutableRevisionRecord + * @throws MWException + * @throws RevisionAccessException + */ + public function newMutableRevisionFromArray( + array $fields, + $queryFlags = 0, + Title $title = null + ) { + if ( !$title && isset( $fields['title'] ) ) { + if ( !( $fields['title'] instanceof Title ) ) { + throw new MWException( 'title field must contain a Title object.' ); + } + + $title = $fields['title']; + } + + if ( !$title ) { + $pageId = $fields['page'] ?? 0; + $revId = $fields['id'] ?? 0; + + $title = $this->getTitle( $pageId, $revId, $queryFlags ); + } + + if ( !isset( $fields['page'] ) ) { + $fields['page'] = $title->getArticleID( $queryFlags ); + } + + // if we have a content object, use it to set the model and type + if ( !empty( $fields['content'] ) ) { + if ( !( $fields['content'] instanceof Content ) && !is_array( $fields['content'] ) ) { + throw new MWException( + 'content field must contain a Content object or an array of Content objects.' + ); + } + } + + if ( !empty( $fields['text_id'] ) ) { + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + throw new MWException( "The text_id field is only available in the pre-MCR schema" ); + } + + if ( !empty( $fields['content'] ) ) { + throw new MWException( + "Text already stored in external store (id {$fields['text_id']}), " . + "can't specify content object" + ); + } + } + + if ( + isset( $fields['comment'] ) + && !( $fields['comment'] instanceof CommentStoreComment ) + ) { + $commentData = $fields['comment_data'] ?? null; + + if ( $fields['comment'] instanceof Message ) { + $fields['comment'] = CommentStoreComment::newUnsavedComment( + $fields['comment'], + $commentData + ); + } else { + $commentText = trim( strval( $fields['comment'] ) ); + $fields['comment'] = CommentStoreComment::newUnsavedComment( + $commentText, + $commentData + ); + } + } + + $revision = new MutableRevisionRecord( $title, $this->wikiId ); + $this->initializeMutableRevisionFromArray( $revision, $fields ); + + if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) { + foreach ( $fields['content'] as $role => $content ) { + $revision->setContent( $role, $content ); + } + } else { + $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title ); + $revision->setSlot( $mainSlot ); + } + + return $revision; + } + + /** + * @param MutableRevisionRecord $record + * @param array $fields + */ + private function initializeMutableRevisionFromArray( + MutableRevisionRecord $record, + array $fields + ) { + /** @var UserIdentity $user */ + $user = null; + + if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) { + $user = $fields['user']; + } else { + try { + $user = User::newFromAnyId( + $fields['user'] ?? null, + $fields['user_text'] ?? null, + $fields['actor'] ?? null + ); + } catch ( InvalidArgumentException $ex ) { + $user = null; + } + } + + if ( $user ) { + $record->setUser( $user ); + } + + $timestamp = isset( $fields['timestamp'] ) + ? strval( $fields['timestamp'] ) + : wfTimestampNow(); // TODO: use a callback, so we can override it for testing. + + $record->setTimestamp( $timestamp ); + + if ( isset( $fields['page'] ) ) { + $record->setPageId( intval( $fields['page'] ) ); + } + + if ( isset( $fields['id'] ) ) { + $record->setId( intval( $fields['id'] ) ); + } + if ( isset( $fields['parent_id'] ) ) { + $record->setParentId( intval( $fields['parent_id'] ) ); + } + + if ( isset( $fields['sha1'] ) ) { + $record->setSha1( $fields['sha1'] ); + } + if ( isset( $fields['size'] ) ) { + $record->setSize( intval( $fields['size'] ) ); + } + + if ( isset( $fields['minor_edit'] ) ) { + $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 ); + } + if ( isset( $fields['deleted'] ) ) { + $record->setVisibility( intval( $fields['deleted'] ) ); + } + + if ( isset( $fields['comment'] ) ) { + Assert::parameterType( + CommentStoreComment::class, + $fields['comment'], + '$row[\'comment\']' + ); + $record->setComment( $fields['comment'] ); + } + } + + /** + * Load a page revision from a given revision ID number. + * Returns null if no such revision can be found. + * + * MCR migration note: this corresponds to Revision::loadFromId + * + * @note direct use is deprecated! + * @todo remove when unused! there seem to be no callers of Revision::loadFromId + * + * @param IDatabase $db + * @param int $id + * + * @return RevisionRecord|null + */ + public function loadRevisionFromId( IDatabase $db, $id ) { + return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page. If not attached + * to that page, will return null. + * + * MCR migration note: this replaces Revision::loadFromPageId + * + * @note direct use is deprecated! + * @todo remove when unused! + * + * @param IDatabase $db + * @param int $pageid + * @param int $id + * @return RevisionRecord|null + */ + public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) { + $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ]; + if ( $id ) { + $conds['rev_id'] = intval( $id ); + } else { + $conds[] = 'rev_id=page_latest'; + } + return $this->loadRevisionFromConds( $db, $conds ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page. If not attached + * to that page, will return null. + * + * MCR migration note: this replaces Revision::loadFromTitle + * + * @note direct use is deprecated! + * @todo remove when unused! + * + * @param IDatabase $db + * @param Title $title + * @param int $id + * + * @return RevisionRecord|null + */ + public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) { + if ( $id ) { + $matchId = intval( $id ); + } else { + $matchId = 'page_latest'; + } + + return $this->loadRevisionFromConds( + $db, + [ + "rev_id=$matchId", + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * Load the revision for the given title with the given timestamp. + * WARNING: Timestamps may in some circumstances not be unique, + * so this isn't the best key to use. + * + * MCR migration note: this replaces Revision::loadFromTimestamp + * + * @note direct use is deprecated! Use getRevisionFromTimestamp instead! + * @todo remove when unused! + * + * @param IDatabase $db + * @param Title $title + * @param string $timestamp + * @return RevisionRecord|null + */ + public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) { + return $this->loadRevisionFromConds( $db, + [ + 'rev_timestamp' => $db->timestamp( $timestamp ), + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * Given a set of conditions, fetch a revision + * + * This method should be used if we are pretty sure the revision exists. + * Unless $flags has READ_LATEST set, this method will first try to find the revision + * on a replica before hitting the master database. + * + * MCR migration note: this corresponds to Revision::newFromConds + * + * @param array $conditions + * @param int $flags (optional) + * @param Title|null $title + * + * @return RevisionRecord|null + */ + private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) { + $db = $this->getDBConnectionRefForQueryFlags( $flags ); + $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title ); + + $lb = $this->getDBLoadBalancer(); + + // Make sure new pending/committed revision are visibile later on + // within web requests to certain avoid bugs like T93866 and T94407. + if ( !$rev + && !( $flags & self::READ_LATEST ) + && $lb->getServerCount() > 1 + && $lb->hasOrMadeRecentMasterChanges() + ) { + $flags = self::READ_LATEST; + $dbw = $this->getDBConnection( DB_MASTER ); + $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title ); + $this->releaseDBConnection( $dbw ); + } + + return $rev; + } + + /** + * Given a set of conditions, fetch a revision from + * the given database connection. + * + * MCR migration note: this corresponds to Revision::loadFromConds + * + * @param IDatabase $db + * @param array $conditions + * @param int $flags (optional) + * @param Title|null $title + * + * @return RevisionRecord|null + */ + private function loadRevisionFromConds( + IDatabase $db, + $conditions, + $flags = 0, + Title $title = null + ) { + $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags ); + if ( $row ) { + $rev = $this->newRevisionFromRow( $row, $flags, $title ); + + return $rev; + } + + return null; + } + + /** + * Throws an exception if the given database connection does not belong to the wiki this + * RevisionStore is bound to. + * + * @param IDatabase $db + * @throws MWException + */ + private function checkDatabaseWikiId( IDatabase $db ) { + $storeWiki = $this->wikiId; + $dbWiki = $db->getDomainID(); + + if ( $dbWiki === $storeWiki ) { + return; + } + + // XXX: we really want the default database ID... + $storeWiki = $storeWiki ?: wfWikiID(); + $dbWiki = $dbWiki ?: wfWikiID(); + + if ( $dbWiki === $storeWiki ) { + return; + } + + // HACK: counteract encoding imposed by DatabaseDomain + $storeWiki = str_replace( '?h', '-', $storeWiki ); + $dbWiki = str_replace( '?h', '-', $dbWiki ); + + if ( $dbWiki === $storeWiki ) { + return; + } + + throw new MWException( "RevisionStore for $storeWiki " + . "cannot be used with a DB connection for $dbWiki" ); + } + + /** + * Given a set of conditions, return a row with the + * fields necessary to build RevisionRecord objects. + * + * MCR migration note: this corresponds to Revision::fetchFromConds + * + * @param IDatabase $db + * @param array $conditions + * @param int $flags (optional) + * + * @return object|false data row as a raw object + */ + private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) { + $this->checkDatabaseWikiId( $db ); + + $revQuery = $this->getQueryInfo( [ 'page', 'user' ] ); + $options = []; + if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) { + $options[] = 'FOR UPDATE'; + } + return $db->selectRow( + $revQuery['tables'], + $revQuery['fields'], + $conditions, + __METHOD__, + $options, + $revQuery['joins'] + ); + } + + /** + * Finds the ID of a content row for a given revision and slot role. + * This can be used to re-use content rows even while the content ID + * is still missing from SlotRecords, when writing to both the old and + * the new schema during MCR schema migration. + * + * @todo remove after MCR schema migration is complete. + * + * @param IDatabase $db + * @param int $revId + * @param string $role + * + * @return int|null + */ + private function findSlotContentId( IDatabase $db, $revId, $role ) { + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { + return null; + } + + try { + $roleId = $this->slotRoleStore->getId( $role ); + $conditions = [ + 'slot_revision_id' => $revId, + 'slot_role_id' => $roleId, + ]; + + $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ ); + + return $contentId ?: null; + } catch ( NameTableAccessException $ex ) { + // If the role is missing from the slot_roles table, + // the corresponding row in slots cannot exist. + return null; + } + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new RevisionStoreRecord object. + * + * MCR migration note: this replaces Revision::getQueryInfo + * + * If the format of fields returned changes in any way then the cache key provided by + * self::getRevisionRowCacheKey should be updated. + * + * @since 1.31 + * + * @param array $options Any combination of the following strings + * - 'page': Join with the page table, and select fields to identify the page + * - 'user': Join with the user table, and select the user name + * - 'text': Join with the text table, and select fields to load page text. This + * option is deprecated in MW 1.32 when the MCR migration flag SCHEMA_COMPAT_WRITE_NEW + * is set, and disallowed when SCHEMA_COMPAT_READ_OLD is not set. + * + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public function getQueryInfo( $options = [] ) { + $ret = [ + 'tables' => [], + 'fields' => [], + 'joins' => [], + ]; + + $ret['tables'][] = 'revision'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'rev_id', + 'rev_page', + 'rev_timestamp', + 'rev_minor_edit', + 'rev_deleted', + 'rev_len', + 'rev_parent_id', + 'rev_sha1', + ] ); + + $commentQuery = $this->commentStore->getJoin( 'rev_comment' ); + $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] ); + $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); + $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); + + $actorQuery = $this->actorMigration->getJoin( 'rev_user' ); + $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] ); + $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] ); + $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] ); + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + $ret['fields'][] = 'rev_text_id'; + + if ( $this->contentHandlerUseDB ) { + $ret['fields'][] = 'rev_content_format'; + $ret['fields'][] = 'rev_content_model'; + } + } + + if ( in_array( 'page', $options, true ) ) { + $ret['tables'][] = 'page'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ] ); + $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ]; + } + + if ( in_array( 'user', $options, true ) ) { + $ret['tables'][] = 'user'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'user_name', + ] ); + $u = $actorQuery['fields']['rev_user']; + $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ]; + } + + if ( in_array( 'text', $options, true ) ) { + if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) { + throw new InvalidArgumentException( 'text table can no longer be joined directly' ); + } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + // NOTE: even when this class is set to not read from the old schema, callers + // should still be able to join against the text table, as long as we are still + // writing the old schema for compatibility. + // TODO: This should trigger a deprecation warning eventually (T200918), but not + // before all known usages are removed (see T198341 and T201164). + // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' ); + } + + $ret['tables'][] = 'text'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'old_text', + 'old_flags' + ] ); + $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ]; + } + + return $ret; + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new SlotRecord. + * + * @since 1.32 + * + * @param array $options Any combination of the following strings + * - 'content': Join with the content table, and select content meta-data fields + * - 'model': Join with the content_models table, and select the model_name field. + * Only applicable if 'content' is also set. + * - 'role': Join with the slot_roles table, and select the role_name field + * + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public function getSlotsQueryInfo( $options = [] ) { + $ret = [ + 'tables' => [], + 'fields' => [], + 'joins' => [], + ]; + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + $db = $this->getDBConnectionRef( DB_REPLICA ); + $ret['tables']['slots'] = 'revision'; + + $ret['fields']['slot_revision_id'] = 'slots.rev_id'; + $ret['fields']['slot_content_id'] = 'NULL'; + $ret['fields']['slot_origin'] = 'slots.rev_id'; + $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN ); + + if ( in_array( 'content', $options, true ) ) { + $ret['fields']['content_size'] = 'slots.rev_len'; + $ret['fields']['content_sha1'] = 'slots.rev_sha1'; + $ret['fields']['content_address'] + = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ); + + if ( $this->contentHandlerUseDB ) { + $ret['fields']['model_name'] = 'slots.rev_content_model'; + } else { + $ret['fields']['model_name'] = 'NULL'; + } + } + } else { + $ret['tables'][] = 'slots'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'slot_revision_id', + 'slot_content_id', + 'slot_origin', + 'slot_role_id', + ] ); + + if ( in_array( 'role', $options, true ) ) { + // Use left join to attach role name, so we still find the revision row even + // if the role name is missing. This triggers a more obvious failure mode. + $ret['tables'][] = 'slot_roles'; + $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ]; + $ret['fields'][] = 'role_name'; + } + + if ( in_array( 'content', $options, true ) ) { + $ret['tables'][] = 'content'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'content_size', + 'content_sha1', + 'content_address', + 'content_model', + ] ); + $ret['joins']['content'] = [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ]; + + if ( in_array( 'model', $options, true ) ) { + // Use left join to attach model name, so we still find the revision row even + // if the model name is missing. This triggers a more obvious failure mode. + $ret['tables'][] = 'content_models'; + $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ]; + $ret['fields'][] = 'model_name'; + } + + } + } + + return $ret; + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new RevisionArchiveRecord object. + * + * MCR migration note: this replaces Revision::getArchiveQueryInfo + * + * @since 1.31 + * + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public function getArchiveQueryInfo() { + $commentQuery = $this->commentStore->getJoin( 'ar_comment' ); + $actorQuery = $this->actorMigration->getJoin( 'ar_user' ); + $ret = [ + 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'], + 'fields' => [ + 'ar_id', + 'ar_page_id', + 'ar_namespace', + 'ar_title', + 'ar_rev_id', + 'ar_timestamp', + 'ar_minor_edit', + 'ar_deleted', + 'ar_len', + 'ar_parent_id', + 'ar_sha1', + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'joins' => $commentQuery['joins'] + $actorQuery['joins'], + ]; + + if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { + $ret['fields'][] = 'ar_text_id'; + + if ( $this->contentHandlerUseDB ) { + $ret['fields'][] = 'ar_content_format'; + $ret['fields'][] = 'ar_content_model'; + } + } + + return $ret; + } + + /** + * Do a batched query for the sizes of a set of revisions. + * + * MCR migration note: this replaces Revision::getParentLengths + * + * @param int[] $revIds + * @return int[] associative array mapping revision IDs from $revIds to the nominal size + * of the corresponding revision. + */ + public function getRevisionSizes( array $revIds ) { + return $this->listRevisionSizes( $this->getDBConnection( DB_REPLICA ), $revIds ); + } + + /** + * Do a batched query for the sizes of a set of revisions. + * + * MCR migration note: this replaces Revision::getParentLengths + * + * @deprecated use RevisionStore::getRevisionSizes instead. + * + * @param IDatabase $db + * @param int[] $revIds + * @return int[] associative array mapping revision IDs from $revIds to the nominal size + * of the corresponding revision. + */ + public function listRevisionSizes( IDatabase $db, array $revIds ) { + $this->checkDatabaseWikiId( $db ); + + $revLens = []; + if ( !$revIds ) { + return $revLens; // empty + } + + $res = $db->select( + 'revision', + [ 'rev_id', 'rev_len' ], + [ 'rev_id' => $revIds ], + __METHOD__ + ); + + foreach ( $res as $row ) { + $revLens[$row->rev_id] = intval( $row->rev_len ); + } + + return $revLens; + } + + /** + * Get previous revision for this title + * + * MCR migration note: this replaces Revision::getPrevious + * + * @param RevisionRecord $rev + * @param Title|null $title if known (optional) + * + * @return RevisionRecord|null + */ + public function getPreviousRevision( RevisionRecord $rev, Title $title = null ) { + if ( $title === null ) { + $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); + } + $prev = $title->getPreviousRevisionID( $rev->getId() ); + if ( $prev ) { + return $this->getRevisionByTitle( $title, $prev ); + } + return null; + } + + /** + * Get next revision for this title + * + * MCR migration note: this replaces Revision::getNext + * + * @param RevisionRecord $rev + * @param Title|null $title if known (optional) + * + * @return RevisionRecord|null + */ + public function getNextRevision( RevisionRecord $rev, Title $title = null ) { + if ( $title === null ) { + $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); + } + $next = $title->getNextRevisionID( $rev->getId() ); + if ( $next ) { + return $this->getRevisionByTitle( $title, $next ); + } + return null; + } + + /** + * Get previous revision Id for this page_id + * This is used to populate rev_parent_id on save + * + * MCR migration note: this corresponds to Revision::getPreviousRevisionId + * + * @param IDatabase $db + * @param RevisionRecord $rev + * + * @return int + */ + private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { + $this->checkDatabaseWikiId( $db ); + + if ( $rev->getPageId() === null ) { + return 0; + } + # Use page_latest if ID is not given + if ( !$rev->getId() ) { + $prevId = $db->selectField( + 'page', 'page_latest', + [ 'page_id' => $rev->getPageId() ], + __METHOD__ + ); + } else { + $prevId = $db->selectField( + 'revision', 'rev_id', + [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ], + __METHOD__, + [ 'ORDER BY' => 'rev_id DESC' ] + ); + } + return intval( $prevId ); + } + + /** + * Get rev_timestamp from rev_id, without loading the rest of the row + * + * MCR migration note: this replaces Revision::getTimestampFromId + * + * @param Title $title + * @param int $id + * @param int $flags + * @return string|bool False if not found + */ + public function getTimestampFromId( $title, $id, $flags = 0 ) { + $db = $this->getDBConnectionRefForQueryFlags( $flags ); + + $conds = [ 'rev_id' => $id ]; + $conds['rev_page'] = $title->getArticleID(); + $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ ); + + return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false; + } + + /** + * Get count of revisions per page...not very efficient + * + * MCR migration note: this replaces Revision::countByPageId + * + * @param IDatabase $db + * @param int $id Page id + * @return int + */ + public function countRevisionsByPageId( IDatabase $db, $id ) { + $this->checkDatabaseWikiId( $db ); + + $row = $db->selectRow( 'revision', + [ 'revCount' => 'COUNT(*)' ], + [ 'rev_page' => $id ], + __METHOD__ + ); + if ( $row ) { + return intval( $row->revCount ); + } + return 0; + } + + /** + * Get count of revisions per page...not very efficient + * + * MCR migration note: this replaces Revision::countByTitle + * + * @param IDatabase $db + * @param Title $title + * @return int + */ + public function countRevisionsByTitle( IDatabase $db, $title ) { + $id = $title->getArticleID(); + if ( $id ) { + return $this->countRevisionsByPageId( $db, $id ); + } + return 0; + } + + /** + * Check if no edits were made by other users since + * the time a user started editing the page. Limit to + * 50 revisions for the sake of performance. + * + * MCR migration note: this replaces Revision::userWasLastToEdit + * + * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression + * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit + * has been deprecated since 1.24. + * + * @param IDatabase $db The Database to perform the check on. + * @param int $pageId The ID of the page in question + * @param int $userId The ID of the user in question + * @param string $since Look at edits since this time + * + * @return bool True if the given user was the only one to edit since the given timestamp + */ + public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { + $this->checkDatabaseWikiId( $db ); + + if ( !$userId ) { + return false; + } + + $revQuery = $this->getQueryInfo(); + $res = $db->select( + $revQuery['tables'], + [ + 'rev_user' => $revQuery['fields']['rev_user'], + ], + [ + 'rev_page' => $pageId, + 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) + ], + __METHOD__, + [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ], + $revQuery['joins'] + ); + foreach ( $res as $row ) { + if ( $row->rev_user != $userId ) { + return false; + } + } + return true; + } + + /** + * Load a revision based on a known page ID and current revision ID from the DB + * + * This method allows for the use of caching, though accessing anything that normally + * requires permission checks (aside from the text) will trigger a small DB lookup. + * + * MCR migration note: this replaces Revision::newKnownCurrent + * + * @param Title $title the associated page title + * @param int $revId current revision of this page. Defaults to $title->getLatestRevID(). + * + * @return RevisionRecord|bool Returns false if missing + */ + public function getKnownCurrentRevision( Title $title, $revId ) { + $db = $this->getDBConnectionRef( DB_REPLICA ); + + $pageId = $title->getArticleID(); + + if ( !$pageId ) { + return false; + } + + if ( !$revId ) { + $revId = $title->getLatestRevID(); + } + + if ( !$revId ) { + wfWarn( + 'No latest revision known for page ' . $title->getPrefixedDBkey() + . ' even though it exists with page ID ' . $pageId + ); + return false; + } + + $row = $this->cache->getWithSetCallback( + // Page/rev IDs passed in from DB to reflect history merges + $this->getRevisionRowCacheKey( $db, $pageId, $revId ), + WANObjectCache::TTL_WEEK, + function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) { + $setOpts += Database::getCacheSetOptions( $db ); + + $conds = [ + 'rev_page' => intval( $pageId ), + 'page_id' => intval( $pageId ), + 'rev_id' => intval( $revId ), + ]; + + $row = $this->fetchRevisionRowFromConds( $db, $conds ); + return $row ?: false; // don't cache negatives + } + ); + + // Reflect revision deletion and user renames + if ( $row ) { + return $this->newRevisionFromRow( $row, 0, $title ); + } else { + return false; + } + } + + /** + * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] ) + * Caching rows without 'page' or 'user' could lead to issues. + * If the format of the rows returned by the query provided by getQueryInfo changes the + * cache key should be updated to avoid conflicts. + * + * @param IDatabase $db + * @param int $pageId + * @param int $revId + * @return string + */ + private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) { + return $this->cache->makeGlobalKey( + self::ROW_CACHE_KEY, + $db->getDomainID(), + $pageId, + $revId + ); + } + + // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc. + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' ); diff --git a/includes/Revision/RevisionStoreFactory.php b/includes/Revision/RevisionStoreFactory.php new file mode 100644 index 0000000000..30ffc997ef --- /dev/null +++ b/includes/Revision/RevisionStoreFactory.php @@ -0,0 +1,137 @@ +dbLoadBalancerFactory = $dbLoadBalancerFactory; + $this->blobStoreFactory = $blobStoreFactory; + $this->nameTables = $nameTables; + $this->cache = $cache; + $this->commentStore = $commentStore; + $this->actorMigration = $actorMigration; + $this->mcrMigrationStage = $migrationStage; + $this->loggerProvider = $loggerProvider; + $this->contentHandlerUseDB = $contentHandlerUseDB; + } + + /** + * @since 1.32 + * + * @param bool|string $wikiId false for the current domain / wikid + * + * @return RevisionStore for the given wikiId with all necessary services and a logger + */ + public function getRevisionStore( $wikiId = false ) { + Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' ); + + $store = new RevisionStore( + $this->dbLoadBalancerFactory->getMainLB( $wikiId ), + $this->blobStoreFactory->newSqlBlobStore( $wikiId ), + $this->cache, // Pass local cache instance; Leave cache sharing to RevisionStore. + $this->commentStore, + $this->nameTables->getContentModels( $wikiId ), + $this->nameTables->getSlotRoles( $wikiId ), + $this->mcrMigrationStage, + $this->actorMigration, + $wikiId + ); + + $store->setLogger( $this->loggerProvider->getLogger( 'RevisionStore' ) ); + $store->setContentHandlerUseDB( $this->contentHandlerUseDB ); + + return $store; + } +} diff --git a/includes/Revision/RevisionStoreRecord.php b/includes/Revision/RevisionStoreRecord.php new file mode 100644 index 0000000000..955cc82de6 --- /dev/null +++ b/includes/Revision/RevisionStoreRecord.php @@ -0,0 +1,226 @@ +mId = intval( $row->rev_id ); + $this->mPageId = intval( $row->rev_page ); + $this->mComment = $comment; + + $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp ); + Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); + + $this->mUser = $user; + $this->mMinorEdit = boolval( $row->rev_minor_edit ); + $this->mTimestamp = $timestamp; + $this->mDeleted = intval( $row->rev_deleted ); + + // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null + // indicates that the parent revision is unknown. As per MW 1.31, the database schema + // allows rev_parent_id to be NULL. + $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null; + $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; + $this->mSha1 = !empty( $row->rev_sha1 ) ? $row->rev_sha1 : null; + + // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of + // page_latest may be in limbo during revision creation. In that case, calling + // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title + // object. During page creation, that bad value would be 0. + if ( isset( $row->page_latest ) ) { + $this->mCurrent = ( $row->rev_id == $row->page_latest ); + } + + // sanity check + if ( + $this->mPageId && $this->mTitle->exists() + && $this->mPageId !== $this->mTitle->getArticleID() + ) { + throw new InvalidArgumentException( + 'The given Title does not belong to page ID ' . $this->mPageId . + ' but actually belongs to ' . $this->mTitle->getArticleID() + ); + } + } + + /** + * MCR migration note: this replaces Revision::isCurrent + * + * @return bool + */ + public function isCurrent() { + return $this->mCurrent; + } + + /** + * MCR migration note: this replaces Revision::isDeleted + * + * @param int $field One of DELETED_* bitfield constants + * + * @return bool + */ + public function isDeleted( $field ) { + if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { + // Current revisions of pages cannot have the content hidden. Skipping this + // check is very useful for Parser as it fetches templates using newKnownCurrent(). + // Calling getVisibility() in that case triggers a verification database query. + return false; // no need to check + } + + return parent::isDeleted( $field ); + } + + protected function userCan( $field, User $user ) { + if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { + // Current revisions of pages cannot have the content hidden. Skipping this + // check is very useful for Parser as it fetches templates using newKnownCurrent(). + // Calling getVisibility() in that case triggers a verification database query. + return true; // no need to check + } + + return parent::userCan( $field, $user ); + } + + /** + * @return int The revision id, never null. + */ + public function getId() { + // overwritten just to add a guarantee to the contract + return parent::getId(); + } + + /** + * @throws RevisionAccessException if the size was unknown and could not be calculated. + * @return string The nominal revision size, never null. May be computed on the fly. + */ + public function getSize() { + // If length is null, calculate and remember it (potentially SLOW!). + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * @throws RevisionAccessException if the hash was unknown and could not be calculated. + * @return string The revision hash, never null. May be computed on the fly. + */ + public function getSha1() { + // If hash is null, calculate it and remember (potentially SLOW!) + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * @param int $audience + * @param User|null $user + * + * @return UserIdentity The identity of the revision author, null if access is forbidden. + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getUser( $audience, $user ); + } + + /** + * @param int $audience + * @param User|null $user + * + * @return CommentStoreComment The revision comment, null if access is forbidden. + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getComment( $audience, $user ); + } + + /** + * @return string timestamp, never null + */ + public function getTimestamp() { + // overwritten just to add a guarantee to the contract + return parent::getTimestamp(); + } + + /** + * @see RevisionStore::isComplete + * + * @return bool always true. + */ + public function isReadyForInsertion() { + return true; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( RevisionStoreRecord::class, 'MediaWiki\Storage\RevisionStoreRecord' ); diff --git a/includes/Revision/SlotRecord.php b/includes/Revision/SlotRecord.php new file mode 100644 index 0000000000..89980f419f --- /dev/null +++ b/includes/Revision/SlotRecord.php @@ -0,0 +1,665 @@ +row; + + return new SlotRecord( $row, function () { + throw new SuppressedDataException( 'Content suppressed!' ); + } ); + } + + /** + * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields. + * The slot's content cannot be overwritten. + * + * @param SlotRecord $slot + * @param array $overrides + * + * @return SlotRecord + */ + private static function newDerived( SlotRecord $slot, array $overrides = [] ) { + $row = clone $slot->row; + $row->slot_id = null; // never copy the row ID! + + foreach ( $overrides as $key => $value ) { + $row->$key = $value; + } + + return new SlotRecord( $row, $slot->content ); + } + + /** + * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord + * of a previous revision. + * + * Note that a SlotRecord constructed this way are intended as prototypes, + * to be used wit newSaved(). They are incomplete, so some getters such as + * getRevision() will fail. + * + * @param SlotRecord $slot + * + * @return SlotRecord + */ + public static function newInherited( SlotRecord $slot ) { + // Sanity check - we can't inherit from a Slot that's not attached to a revision. + $slot->getRevision(); + $slot->getOrigin(); + $slot->getAddress(); + + // NOTE: slot_origin and content_address are copied from $slot. + return self::newDerived( $slot, [ + 'slot_revision_id' => null, + ] ); + } + + /** + * Constructs a new Slot from a Content object for a new revision. + * This is the preferred way to construct a slot for storing Content that + * resulted from a user edit. The slot is assumed to be not inherited. + * + * Note that a SlotRecord constructed this way are intended as prototypes, + * to be used wit newSaved(). They are incomplete, so some getters such as + * getAddress() will fail. + * + * @param string $role + * @param Content $content + * + * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later. + */ + public static function newUnsaved( $role, Content $content ) { + Assert::parameterType( 'string', $role, '$role' ); + + $row = [ + 'slot_id' => null, // not yet known + 'slot_revision_id' => null, // not yet known + 'slot_origin' => null, // not yet known, will be set in newSaved() + 'content_size' => null, // compute later + 'content_sha1' => null, // compute later + 'slot_content_id' => null, // not yet known, will be set in newSaved() + 'content_address' => null, // not yet known, will be set in newSaved() + 'role_name' => $role, + 'model_name' => $content->getModel(), + ]; + + return new SlotRecord( (object)$row, $content ); + } + + /** + * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete + * proto-slot. This adds information that has only become available during saving, + * particularly the revision ID, content ID and content address. + * + * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id). + * If $protoSlot already has a revision, it must be the same. + * @param int|null $contentId the ID of the row in the content table describing the content + * referenced by $contentAddress (field slot_content_id). + * If $protoSlot already has a content ID, it must be the same. + * @param string $contentAddress the slot's content address (field content_address). + * If $protoSlot already has an address, it must be the same. + * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new + * revision. $protoSlot must have a content address if inherited. + * + * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision. + */ + public static function newSaved( + $revisionId, + $contentId, + $contentAddress, + SlotRecord $protoSlot + ) { + Assert::parameterType( 'integer', $revisionId, '$revisionId' ); + // TODO once migration is over $contentId must be an integer + Assert::parameterType( 'integer|null', $contentId, '$contentId' ); + Assert::parameterType( 'string', $contentAddress, '$contentAddress' ); + + if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) { + throw new LogicException( + "Mismatching revision ID $revisionId: " + . "The slot already belongs to revision {$protoSlot->getRevision()}. " + . "Use SlotRecord::newInherited() to re-use content between revisions." + ); + } + + if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) { + throw new LogicException( + "Mismatching blob address $contentAddress: " + . "The slot already has content at {$protoSlot->getAddress()}." + ); + } + + if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) { + throw new LogicException( + "Mismatching content ID $contentId: " + . "The slot already has content row {$protoSlot->getContentId()} associated." + ); + } + + if ( $protoSlot->isInherited() ) { + if ( !$protoSlot->hasAddress() ) { + throw new InvalidArgumentException( + "An inherited blob should have a content address!" + ); + } + if ( !$protoSlot->hasField( 'slot_origin' ) ) { + throw new InvalidArgumentException( + "A saved inherited slot should have an origin set!" + ); + } + $origin = $protoSlot->getOrigin(); + } else { + $origin = $revisionId; + } + + return self::newDerived( $protoSlot, [ + 'slot_revision_id' => $revisionId, + 'slot_content_id' => $contentId, + 'slot_origin' => $origin, + 'content_address' => $contentAddress, + ] ); + } + + /** + * SlotRecord constructor. + * + * The following fields are supported by the $row parameter: + * + * $row->blob_data + * $row->blob_address + * + * @param object $row A database row composed of fields of the slot and content tables, + * as a raw object. Any field value can be a callback that produces the field value + * given this SlotRecord as a parameter. However, plain strings cannot be used as + * callbacks here, for security reasons. + * @param Content|callable $content The content object associated with the slot, or a + * callback that will return that Content object, given this SlotRecord as a parameter. + */ + public function __construct( $row, $content ) { + Assert::parameterType( 'object', $row, '$row' ); + Assert::parameterType( 'Content|callable', $content, '$content' ); + + Assert::parameter( + property_exists( $row, 'slot_revision_id' ), + '$row->slot_revision_id', + 'must exist' + ); + Assert::parameter( + property_exists( $row, 'slot_content_id' ), + '$row->slot_content_id', + 'must exist' + ); + Assert::parameter( + property_exists( $row, 'content_address' ), + '$row->content_address', + 'must exist' + ); + Assert::parameter( + property_exists( $row, 'model_name' ), + '$row->model_name', + 'must exist' + ); + Assert::parameter( + property_exists( $row, 'slot_origin' ), + '$row->slot_origin', + 'must exist' + ); + Assert::parameter( + !property_exists( $row, 'slot_inherited' ), + '$row->slot_inherited', + 'must not exist' + ); + Assert::parameter( + !property_exists( $row, 'slot_revision' ), + '$row->slot_revision', + 'must not exist' + ); + + $this->row = $row; + $this->content = $content; + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * Returns the Content of the given slot. + * + * @note This is free to load Content from whatever subsystem is necessary, + * performing potentially expensive operations and triggering I/O-related + * failure modes. + * + * @note This method does not apply audience filtering. + * + * @throws SuppressedDataException if access to the content is not allowed according + * to the audience check performed by RevisionRecord::getSlot(). + * + * @return Content The slot's content. This is a direct reference to the internal instance, + * copy before exposing to application logic! + */ + public function getContent() { + if ( $this->content instanceof Content ) { + return $this->content; + } + + $obj = call_user_func( $this->content, $this ); + + Assert::postcondition( + $obj instanceof Content, + 'Slot content callback should return a Content object' + ); + + $this->content = $obj; + + return $this->content; + } + + /** + * Returns the string value of a data field from the database row supplied to the constructor. + * If the field was set to a callback, that callback is invoked and the result returned. + * + * @param string $name + * + * @throws OutOfBoundsException + * @throws IncompleteRevisionException + * @return mixed Returns the field's value, never null. + */ + private function getField( $name ) { + if ( !isset( $this->row->$name ) ) { + // distinguish between unknown and uninitialized fields + if ( property_exists( $this->row, $name ) ) { + throw new IncompleteRevisionException( 'Uninitialized field: ' . $name ); + } else { + throw new OutOfBoundsException( 'No such field: ' . $name ); + } + } + + $value = $this->row->$name; + + // NOTE: allow callbacks, but don't trust plain string callables from the database! + if ( !is_string( $value ) && is_callable( $value ) ) { + $value = call_user_func( $value, $this ); + $this->setField( $name, $value ); + } + + return $value; + } + + /** + * Returns the string value of a data field from the database row supplied to the constructor. + * + * @param string $name + * + * @throws OutOfBoundsException + * @throws IncompleteRevisionException + * @return string Returns the string value + */ + private function getStringField( $name ) { + return strval( $this->getField( $name ) ); + } + + /** + * Returns the int value of a data field from the database row supplied to the constructor. + * + * @param string $name + * + * @throws OutOfBoundsException + * @throws IncompleteRevisionException + * @return int Returns the int value + */ + private function getIntField( $name ) { + return intval( $this->getField( $name ) ); + } + + /** + * @param string $name + * @return bool whether this record contains the given field + */ + private function hasField( $name ) { + if ( isset( $this->row->$name ) ) { + // if the field is a callback, resolve first, then re-check + if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) { + $this->getField( $name ); + } + } + + return isset( $this->row->$name ); + } + + /** + * Returns the ID of the revision this slot is associated with. + * + * @return int + */ + public function getRevision() { + return $this->getIntField( 'slot_revision_id' ); + } + + /** + * Returns the revision ID of the revision that originated the slot's content. + * + * @return int + */ + public function getOrigin() { + return $this->getIntField( 'slot_origin' ); + } + + /** + * Whether this slot was inherited from an older revision. + * + * If this SlotRecord is already attached to a revision, this returns true + * if the slot's revision of origin is the same as the revision it belongs to. + * + * If this SlotRecord is not yet attached to a revision, this returns true + * if the slot already has an address. + * + * @return bool + */ + public function isInherited() { + if ( $this->hasRevision() ) { + return $this->getRevision() !== $this->getOrigin(); + } else { + return $this->hasAddress(); + } + } + + /** + * Whether this slot has an address. Slots will have an address if their + * content has been stored. While building a new revision, + * SlotRecords will not have an address associated. + * + * @return bool + */ + public function hasAddress() { + return $this->hasField( 'content_address' ); + } + + /** + * Whether this slot has an origin (revision ID that originated the slot's content. + * + * @since 1.32 + * + * @return bool + */ + public function hasOrigin() { + return $this->hasField( 'slot_origin' ); + } + + /** + * Whether this slot has a content ID. Slots will have a content ID if their + * content has been stored in the content table. While building a new revision, + * SlotRecords will not have an ID associated. + * + * Also, during schema migration, hasContentId() may return false when encountering an + * un-migrated database entry in SCHEMA_COMPAT_WRITE_BOTH mode. + * It will however always return true for saved revisions on SCHEMA_COMPAT_READ_NEW mode, + * or without SCHEMA_COMPAT_WRITE_NEW mode. In the latter case, an emulated content ID + * is used, derived from the revision's text ID. + * + * Note that hasContentId() returning false while hasRevision() returns true always + * indicates an unmigrated row in SCHEMA_COMPAT_WRITE_BOTH mode, as described above. + * For an unsaved slot, both these methods would return false. + * + * @since 1.32 + * + * @return bool + */ + public function hasContentId() { + return $this->hasField( 'slot_content_id' ); + } + + /** + * Whether this slot has revision ID associated. Slots will have a revision ID associated + * only if they were loaded as part of an existing revision. While building a new revision, + * Slotrecords will not have a revision ID associated. + * + * @return bool + */ + public function hasRevision() { + return $this->hasField( 'slot_revision_id' ); + } + + /** + * Returns the role of the slot. + * + * @return string + */ + public function getRole() { + return $this->getStringField( 'role_name' ); + } + + /** + * Returns the address of this slot's content. + * This address can be used with BlobStore to load the Content object. + * + * @return string + */ + public function getAddress() { + return $this->getStringField( 'content_address' ); + } + + /** + * Returns the ID of the content meta data row associated with the slot. + * This information should be irrelevant to application logic, it is here to allow + * the construction of a full row for the revision table. + * + * Note that this method may return an emulated value during schema migration in + * SCHEMA_COMPAT_WRITE_OLD mode. See RevisionStore::emulateContentId for more information. + * + * @return int + */ + public function getContentId() { + return $this->getIntField( 'slot_content_id' ); + } + + /** + * Returns the content size + * + * @return int size of the content, in bogo-bytes, as reported by Content::getSize. + */ + public function getSize() { + try { + $size = $this->getIntField( 'content_size' ); + } catch ( IncompleteRevisionException $ex ) { + $size = $this->getContent()->getSize(); + $this->setField( 'content_size', $size ); + } + + return $size; + } + + /** + * Returns the content size + * + * @return string hash of the content. + */ + public function getSha1() { + try { + $sha1 = $this->getStringField( 'content_sha1' ); + } catch ( IncompleteRevisionException $ex ) { + $format = $this->hasField( 'format_name' ) + ? $this->getStringField( 'format_name' ) + : null; + + $data = $this->getContent()->serialize( $format ); + $sha1 = self::base36Sha1( $data ); + $this->setField( 'content_sha1', $sha1 ); + } + + return $sha1; + } + + /** + * Returns the content model. This is the model name that decides + * which ContentHandler is appropriate for interpreting the + * data of the blob referenced by the address returned by getAddress(). + * + * @return string the content model of the content + */ + public function getModel() { + try { + $model = $this->getStringField( 'model_name' ); + } catch ( IncompleteRevisionException $ex ) { + $model = $this->getContent()->getModel(); + $this->setField( 'model_name', $model ); + } + + return $model; + } + + /** + * Returns the blob serialization format as a MIME type. + * + * @note When this method returns null, the caller is expected + * to auto-detect the serialization format, or to rely on + * the default format associated with the content model. + * + * @return string|null + */ + public function getFormat() { + // XXX: we currently do not plan to store the format for each slot! + + if ( $this->hasField( 'format_name' ) ) { + return $this->getStringField( 'format_name' ); + } + + return null; + } + + /** + * @param string $name + * @param string|int|null $value + */ + private function setField( $name, $value ) { + $this->row->$name = $value; + } + + /** + * Get the base 36 SHA-1 value for a string of text + * + * MCR migration note: this replaces Revision::base36Sha1 + * + * @param string $blob + * @return string + */ + public static function base36Sha1( $blob ) { + return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 ); + } + + /** + * Returns true if $other has the same content as this slot. + * The check is performed based on the model, address size, and hash. + * Two slots can have the same content if they use different content addresses, + * but if they have the same address and the same model, they have the same content. + * Two slots can have the same content if they belong to different + * revisions or pages. + * + * Note that hasSameContent() may return false even if Content::equals returns true for + * the content of two slots. This may happen if the two slots have different serializations + * representing equivalent Content. Such false negatives are considered acceptable. Code + * that has to be absolutely sure the Content is really not the same if hasSameContent() + * returns false should call getContent() and compare the Content objects directly. + * + * @since 1.32 + * + * @param SlotRecord $other + * @return bool + */ + public function hasSameContent( SlotRecord $other ) { + if ( $other === $this ) { + return true; + } + + if ( $this->getModel() !== $other->getModel() ) { + return false; + } + + if ( $this->hasAddress() + && $other->hasAddress() + && $this->getAddress() == $other->getAddress() + ) { + return true; + } + + if ( $this->getSize() !== $other->getSize() ) { + return false; + } + + if ( $this->getSha1() !== $other->getSha1() ) { + return false; + } + + return true; + } + +} + +/** + * Retain the old class name for backwards compatibility. + * @deprecated since 1.32 + */ +class_alias( SlotRecord::class, 'MediaWiki\Storage\SlotRecord' ); diff --git a/includes/Revision/SlotRenderingProvider.php b/includes/Revision/SlotRenderingProvider.php index 740f0f2c9f..2c06b312ba 100644 --- a/includes/Revision/SlotRenderingProvider.php +++ b/includes/Revision/SlotRenderingProvider.php @@ -7,7 +7,6 @@ */ namespace MediaWiki\Revision; -use MediaWiki\Storage\SuppressedDataException; use ParserOutput; /** diff --git a/includes/Revision/SuppressedDataException.php b/includes/Revision/SuppressedDataException.php new file mode 100644 index 0000000000..b7e60d6049 --- /dev/null +++ b/includes/Revision/SuppressedDataException.php @@ -0,0 +1,40 @@ +getPageAsLinkTarget() ); - $rev = new MutableRevisionRecord( $title, $parent->getWikiId() ); - - foreach ( $parent->getSlotRoles() as $role ) { - $slot = $parent->getSlot( $role, self::RAW ); - $rev->inheritSlot( $slot ); - } - - $rev->setPageId( $parent->getPageId() ); - $rev->setParentId( $parent->getId() ); - - return $rev; - } - - /** - * @note Avoid calling this constructor directly. Use the appropriate methods - * in RevisionStore instead. - * - * @param Title $title The title of the page this Revision is associated with. - * @param bool|string $wikiId the wiki ID of the site this Revision belongs to, - * or false for the local site. - * - * @throws MWException - */ - function __construct( Title $title, $wikiId = false ) { - $slots = new MutableRevisionSlots(); - - parent::__construct( $title, $slots, $wikiId ); - - $this->mSlots = $slots; // redundant, but nice for static analysis - } - - /** - * @param int $parentId - */ - public function setParentId( $parentId ) { - Assert::parameterType( 'integer', $parentId, '$parentId' ); - - $this->mParentId = $parentId; - } - - /** - * Sets the given slot. If a slot with the same role is already present in the revision, - * it is replaced. - * - * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a - * SlotRecord from another revision should use inheritSlot(). Calling code that has access to - * a Content object can use setContent(). - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @note Calling this method will cause the revision size and hash to be re-calculated upon - * the next call to getSize() and getSha1(), respectively. - * - * @param SlotRecord $slot - */ - public function setSlot( SlotRecord $slot ) { - if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) { - throw new InvalidArgumentException( - 'The given slot must be an unsaved, unattached one. ' - . 'This slot is already attached to revision ' . $slot->getRevision() . '. ' - . 'Use inheritSlot() instead to preserve a slot from a previous revision.' - ); - } - - $this->mSlots->setSlot( $slot ); - $this->resetAggregateValues(); - } - - /** - * "Inherits" the given slot's content. - * - * If a slot with the same role is already present in the revision, it is replaced. - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @param SlotRecord $parentSlot - */ - public function inheritSlot( SlotRecord $parentSlot ) { - $this->mSlots->inheritSlot( $parentSlot ); - $this->resetAggregateValues(); - } - - /** - * Sets the content for the slot with the given role. - * - * If a slot with the same role is already present in the revision, it is replaced. - * Calling code that has access to a SlotRecord can use inheritSlot() instead. - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @note Calling this method will cause the revision size and hash to be re-calculated upon - * the next call to getSize() and getSha1(), respectively. - * - * @param string $role - * @param Content $content - */ - public function setContent( $role, Content $content ) { - $this->mSlots->setContent( $role, $content ); - $this->resetAggregateValues(); - } - - /** - * Removes the slot with the given role from this revision. - * This effectively ends the "stream" with that role on the revision's page. - * Future revisions will no longer inherit this slot, unless it is added back explicitly. - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @note Calling this method will cause the revision size and hash to be re-calculated upon - * the next call to getSize() and getSha1(), respectively. - * - * @param string $role - */ - public function removeSlot( $role ) { - $this->mSlots->removeSlot( $role ); - $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 - */ - public function setComment( CommentStoreComment $comment ) { - $this->mComment = $comment; - } - - /** - * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash. - * - * @note This should only be used if the calling code is sure that the given hash is correct - * for the revision's content, and there is no chance of the content being manipulated - * later. When in doubt, this method should not be called. - * - * @param string $sha1 SHA1 hash as a base36 string. - */ - public function setSha1( $sha1 ) { - Assert::parameterType( 'string', $sha1, '$sha1' ); - - $this->mSha1 = $sha1; - } - - /** - * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size. - * - * @note This should only be used if the calling code is sure that the given size is correct - * for the revision's content, and there is no chance of the content being manipulated - * later. When in doubt, this method should not be called. - * - * @param int $size nominal size in bogo-bytes - */ - public function setSize( $size ) { - Assert::parameterType( 'integer', $size, '$size' ); - - $this->mSize = $size; - } - - /** - * @param int $visibility - */ - public function setVisibility( $visibility ) { - Assert::parameterType( 'integer', $visibility, '$visibility' ); - - $this->mDeleted = $visibility; - } - - /** - * @param string $timestamp A timestamp understood by wfTimestamp - */ - public function setTimestamp( $timestamp ) { - Assert::parameterType( 'string', $timestamp, '$timestamp' ); - - $this->mTimestamp = wfTimestamp( TS_MW, $timestamp ); - } - - /** - * @param bool $minorEdit - */ - public function setMinorEdit( $minorEdit ) { - Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' ); - - $this->mMinorEdit = $minorEdit; - } - - /** - * Set the revision ID. - * - * MCR migration note: this replaces Revision::setId() - * - * @warning Use this with care, especially when preparing a revision for insertion - * into the database! The revision ID should only be fixed in special cases - * like preserving the original ID when restoring a revision. - * - * @param int $id - */ - public function setId( $id ) { - Assert::parameterType( 'integer', $id, '$id' ); - - $this->mId = $id; - } - - /** - * Sets the user identity associated with the revision - * - * @param UserIdentity $user - */ - public function setUser( UserIdentity $user ) { - $this->mUser = $user; - } - - /** - * @param int $pageId - */ - public function setPageId( $pageId ) { - Assert::parameterType( 'integer', $pageId, '$pageId' ); - - if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) { - throw new InvalidArgumentException( - 'The given Title does not belong to page ID ' . $this->mPageId - ); - } - - $this->mPageId = $pageId; - } - - /** - * Returns the nominal size of this revision. - * - * MCR migration note: this replaces Revision::getSize - * - * @return int The nominal size, may be computed on the fly if not yet known. - */ - public function getSize() { - // If not known, re-calculate and remember. Will be reset when slots change. - if ( $this->mSize === null ) { - $this->mSize = $this->mSlots->computeSize(); - } - - return $this->mSize; - } - - /** - * Returns the base36 sha1 of this revision. - * - * MCR migration note: this replaces Revision::getSha1 - * - * @return string The revision hash, may be computed on the fly if not yet known. - */ - public function getSha1() { - // If not known, re-calculate and remember. Will be reset when slots change. - if ( $this->mSha1 === null ) { - $this->mSha1 = $this->mSlots->computeSha1(); - } - - return $this->mSha1; - } - - /** - * Returns the slots defined for this revision as a MutableRevisionSlots instance, - * which can be modified to defined the slots for this revision. - * - * @return MutableRevisionSlots - */ - public function getSlots() { - // Overwritten just guarantee the more narrow return type. - return parent::getSlots(); - } - - /** - * Invalidate cached aggregate values such as hash and size. - */ - private function resetAggregateValues() { - $this->mSize = null; - $this->mSha1 = null; - } - -} diff --git a/includes/Storage/MutableRevisionSlots.php b/includes/Storage/MutableRevisionSlots.php deleted file mode 100644 index df949649af..0000000000 --- a/includes/Storage/MutableRevisionSlots.php +++ /dev/null @@ -1,109 +0,0 @@ -getRole(); - $inherited[$role] = SlotRecord::newInherited( $slot ); - } - - return new MutableRevisionSlots( $inherited ); - } - - /** - * @param SlotRecord[] $slots An array of SlotRecords. - */ - public function __construct( array $slots = [] ) { - parent::__construct( $slots ); - } - - /** - * Sets the given slot. - * If a slot with the same role is already present, it is replaced. - * - * @param SlotRecord $slot - */ - public function setSlot( SlotRecord $slot ) { - if ( !is_array( $this->slots ) ) { - $this->getSlots(); // initialize $this->slots - } - - $role = $slot->getRole(); - $this->slots[$role] = $slot; - } - - /** - * Sets the given slot to an inherited version of $slot. - * If a slot with the same role is already present, it is replaced. - * - * @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 - */ - public function setContent( $role, Content $content ) { - $slot = SlotRecord::newUnsaved( $role, $content ); - $this->setSlot( $slot ); - } - - /** - * Remove the slot for the given role, discontinue the corresponding stream. - * - * @param string $role - */ - public function removeSlot( $role ) { - if ( !is_array( $this->slots ) ) { - $this->getSlots(); // initialize $this->slots - } - - unset( $this->slots[$role] ); - } - -} diff --git a/includes/Storage/PageUpdater.php b/includes/Storage/PageUpdater.php index 29ce710212..043e00ebf6 100644 --- a/includes/Storage/PageUpdater.php +++ b/includes/Storage/PageUpdater.php @@ -35,6 +35,11 @@ use InvalidArgumentException; use LogicException; use ManualLogEntry; use MediaWiki\Linker\LinkTarget; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\RevisionAccessException; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\RevisionStore; +use MediaWiki\Revision\SlotRecord; use MWException; use RecentChange; use Revision; diff --git a/includes/Storage/RevisionAccessException.php b/includes/Storage/RevisionAccessException.php deleted file mode 100644 index ee6efc0a0c..0000000000 --- a/includes/Storage/RevisionAccessException.php +++ /dev/null @@ -1,34 +0,0 @@ -ar_timestamp ); - Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); - - $this->mArchiveId = intval( $row->ar_id ); - - // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases, - // notably when a partially restored page has been moved, and a new page has been created - // with the same title. Archive rows for that title will then have the wrong page id. - $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID(); - - // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null - // indicates that the parent revision is unknown. As per MW 1.31, the database schema - // allows ar_parent_id to be NULL. - $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null; - $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null; - $this->mComment = $comment; - $this->mUser = $user; - $this->mTimestamp = $timestamp; - $this->mMinorEdit = boolval( $row->ar_minor_edit ); - $this->mDeleted = intval( $row->ar_deleted ); - $this->mSize = isset( $row->ar_len ) ? intval( $row->ar_len ) : null; - $this->mSha1 = !empty( $row->ar_sha1 ) ? $row->ar_sha1 : null; - } - - /** - * Get archive row ID - * - * @return int - */ - public function getArchiveId() { - return $this->mArchiveId; - } - - /** - * @return int|null The revision id, or null if the original revision ID - * was not recorded in the archive table. - */ - public function getId() { - // overwritten just to refine the contract specification. - return parent::getId(); - } - - /** - * @throws RevisionAccessException if the size was unknown and could not be calculated. - * @return int The nominal revision size, never null. May be computed on the fly. - */ - public function getSize() { - // If length is null, calculate and remember it (potentially SLOW!). - // This is for compatibility with old database rows that don't have the field set. - if ( $this->mSize === null ) { - $this->mSize = $this->mSlots->computeSize(); - } - - return $this->mSize; - } - - /** - * @throws RevisionAccessException if the hash was unknown and could not be calculated. - * @return string The revision hash, never null. May be computed on the fly. - */ - public function getSha1() { - // If hash is null, calculate it and remember (potentially SLOW!) - // This is for compatibility with old database rows that don't have the field set. - if ( $this->mSha1 === null ) { - $this->mSha1 = $this->mSlots->computeSha1(); - } - - return $this->mSha1; - } - - /** - * @param int $audience - * @param User|null $user - * - * @return UserIdentity The identity of the revision author, null if access is forbidden. - */ - public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { - // overwritten just to add a guarantee to the contract - return parent::getUser( $audience, $user ); - } - - /** - * @param int $audience - * @param User|null $user - * - * @return CommentStoreComment The revision comment, null if access is forbidden. - */ - public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { - // overwritten just to add a guarantee to the contract - return parent::getComment( $audience, $user ); - } - - /** - * @return string never null - */ - public function getTimestamp() { - // overwritten just to add a guarantee to the contract - return parent::getTimestamp(); - } - - /** - * @see RevisionStore::isComplete - * - * @return bool always true. - */ - public function isReadyForInsertion() { - return true; - } - -} diff --git a/includes/Storage/RevisionFactory.php b/includes/Storage/RevisionFactory.php deleted file mode 100644 index 2c4546827a..0000000000 --- a/includes/Storage/RevisionFactory.php +++ /dev/null @@ -1,94 +0,0 @@ -ar_user, etc. - * - * @return RevisionRecord - */ - public function newRevisionFromArchiveRow( - $row, - $queryFlags = 0, - Title $title = null, - array $overrides = [] - ); - -} diff --git a/includes/Storage/RevisionLookup.php b/includes/Storage/RevisionLookup.php deleted file mode 100644 index a6e29309fb..0000000000 --- a/includes/Storage/RevisionLookup.php +++ /dev/null @@ -1,120 +0,0 @@ -mTitle = $title; - $this->mSlots = $slots; - $this->mWiki = $wikiId; - - // XXX: this is a sensible default, but we may not have a Title object here in the future. - $this->mPageId = $title->getArticleID(); - } - - /** - * Implemented to defy serialization. - * - * @throws LogicException always - */ - public function __sleep() { - throw new LogicException( __CLASS__ . ' is not serializable.' ); - } - - /** - * @param RevisionRecord $rec - * - * @return bool True if this RevisionRecord is known to have same content as $rec. - * False if the content is different (or not known to be the same). - */ - public function hasSameContent( RevisionRecord $rec ) { - if ( $rec === $this ) { - return true; - } - - if ( $this->getId() !== null && $this->getId() === $rec->getId() ) { - return true; - } - - // check size before hash, since size is quicker to compute - if ( $this->getSize() !== $rec->getSize() ) { - return false; - } - - // instead of checking the hash, we could also check the content addresses of all slots. - - if ( $this->getSha1() === $rec->getSha1() ) { - return true; - } - - return false; - } - - /** - * Returns the Content of the given slot of this revision. - * Call getSlotNames() to get a list of available slots. - * - * Note that for mutable Content objects, each call to this method will return a - * fresh clone. - * - * MCR migration note: this replaces Revision::getContent - * - * @param string $role The role name of the desired slot - * @param int $audience - * @param User|null $user - * - * @throws RevisionAccessException if the slot does not exist or slot data - * could not be lazy-loaded. - * @return Content|null The content of the given slot, or null if access is forbidden. - */ - public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) { - // XXX: throwing an exception would be nicer, but would a further - // departure from the signature of Revision::getContent(), and thus - // more complex and error prone refactoring. - if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { - return null; - } - - $content = $this->getSlot( $role, $audience, $user )->getContent(); - return $content->copy(); - } - - /** - * Returns meta-data for the given slot. - * - * @param string $role The role name of the desired slot - * @param int $audience - * @param User|null $user - * - * @throws RevisionAccessException if the slot does not exist or slot data - * could not be lazy-loaded. - * @return SlotRecord The slot meta-data. If access to the slot content is forbidden, - * calling getContent() on the SlotRecord will throw an exception. - */ - public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) { - $slot = $this->mSlots->getSlot( $role ); - - if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { - return SlotRecord::newWithSuppressedContent( $slot ); - } - - return $slot; - } - - /** - * Returns whether the given slot is defined in this revision. - * - * @param string $role The role name of the desired slot - * - * @return bool - */ - public function hasSlot( $role ) { - return $this->mSlots->hasSlot( $role ); - } - - /** - * Returns the slot names (roles) of all slots present in this revision. - * getContent() will succeed only for the names returned by this method. - * - * @return string[] - */ - public function getSlotRoles() { - 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 - * in the database). - * - * MCR migration note: this replaces Revision::getId - * - * @return int|null - */ - public function getId() { - return $this->mId; - } - - /** - * Get parent revision ID (the original previous page revision). - * If there is no parent revision, this returns 0. - * If the parent revision is undefined or unknown, this returns null. - * - * @note As of MW 1.31, the database schema allows the parent ID to be - * NULL to indicate that it is unknown. - * - * MCR migration note: this replaces Revision::getParentId - * - * @return int|null - */ - public function getParentId() { - return $this->mParentId; - } - - /** - * Returns the nominal size of this revision, in bogo-bytes. - * May be calculated on the fly if not known, which may in the worst - * case may involve loading all content. - * - * MCR migration note: this replaces Revision::getSize - * - * @throws RevisionAccessException if the size was unknown and could not be calculated. - * @return int - */ - abstract public function getSize(); - - /** - * Returns the base36 sha1 of this revision. This hash is derived from the - * hashes of all slots associated with the revision. - * May be calculated on the fly if not known, which may in the worst - * case may involve loading all content. - * - * MCR migration note: this replaces Revision::getSha1 - * - * @throws RevisionAccessException if the hash was unknown and could not be calculated. - * @return string - */ - abstract public function getSha1(); - - /** - * Get the page ID. If the page does not yet exist, the page ID is 0. - * - * MCR migration note: this replaces Revision::getPage - * - * @return int - */ - public function getPageId() { - return $this->mPageId; - } - - /** - * Get the ID of the wiki this revision belongs to. - * - * @return string|false The wiki's logical name, of false to indicate the local wiki. - */ - public function getWikiId() { - return $this->mWiki; - } - - /** - * Returns the title of the page this revision is associated with as a LinkTarget object. - * - * MCR migration note: this replaces Revision::getTitle - * - * @return LinkTarget - */ - public function getPageAsLinkTarget() { - return $this->mTitle; - } - - /** - * Fetch revision's author's user identity, if it's available to the specified audience. - * If the specified audience does not have access to it, null will be - * returned. Depending on the concrete subclass, null may also be returned if the user is - * not yet specified. - * - * MCR migration note: this replaces Revision::getUser - * - * @param int $audience One of: - * RevisionRecord::FOR_PUBLIC to be displayed to all users - * RevisionRecord::FOR_THIS_USER to be displayed to the given user - * RevisionRecord::RAW get the ID regardless of permissions - * @param User|null $user User object to check for, only if FOR_THIS_USER is passed - * to the $audience parameter - * @return UserIdentity|null - */ - public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { - if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) { - return null; - } else { - return $this->mUser; - } - } - - /** - * Fetch revision comment, if it's available to the specified audience. - * If the specified audience does not have access to the comment, - * this will return null. Depending on the concrete subclass, null may also be returned - * if the comment is not yet specified. - * - * MCR migration note: this replaces Revision::getComment - * - * @param int $audience One of: - * RevisionRecord::FOR_PUBLIC to be displayed to all users - * RevisionRecord::FOR_THIS_USER to be displayed to the given user - * RevisionRecord::RAW get the text regardless of permissions - * @param User|null $user User object to check for, only if FOR_THIS_USER is passed - * to the $audience parameter - * - * @return CommentStoreComment|null - */ - public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { - if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) { - return null; - } else { - return $this->mComment; - } - } - - /** - * MCR migration note: this replaces Revision::isMinor - * - * @return bool - */ - public function isMinor() { - return (bool)$this->mMinorEdit; - } - - /** - * MCR migration note: this replaces Revision::isDeleted - * - * @param int $field One of DELETED_* bitfield constants - * - * @return bool - */ - public function isDeleted( $field ) { - return ( $this->getVisibility() & $field ) == $field; - } - - /** - * Get the deletion bitfield of the revision - * - * MCR migration note: this replaces Revision::getVisibility - * - * @return int - */ - public function getVisibility() { - return (int)$this->mDeleted; - } - - /** - * MCR migration note: this replaces Revision::getTimestamp. - * - * May return null if the timestamp was not specified. - * - * @return string|null - */ - public function getTimestamp() { - return $this->mTimestamp; - } - - /** - * Check that the given audience has access to the given field. - * - * MCR migration note: this corresponds to Revision::userCan - * - * @param int $field One of self::DELETED_TEXT, - * self::DELETED_COMMENT, - * self::DELETED_USER - * @param int $audience One of: - * RevisionRecord::FOR_PUBLIC to be displayed to all users - * RevisionRecord::FOR_THIS_USER to be displayed to the given user - * RevisionRecord::RAW get the text regardless of permissions - * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER, - * ignored otherwise. - * - * @return bool - */ - public function audienceCan( $field, $audience, User $user = null ) { - if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) { - return false; - } elseif ( $audience == self::FOR_THIS_USER ) { - if ( !$user ) { - throw new InvalidArgumentException( - 'A User object must be given when checking FOR_THIS_USER audience.' - ); - } - - if ( !$this->userCan( $field, $user ) ) { - return false; - } - } - - return true; - } - - /** - * Determine if the current user is allowed to view a particular - * field of this revision, if it's marked as deleted. - * - * MCR migration note: this corresponds to Revision::userCan - * - * @param int $field One of self::DELETED_TEXT, - * self::DELETED_COMMENT, - * self::DELETED_USER - * @param User $user User object to check - * @return bool - */ - protected function userCan( $field, User $user ) { - // TODO: use callback for permission checks, so we don't need to know a Title object! - return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle ); - } - - /** - * Determine if the current user is allowed to view a particular - * field of this revision, if it's marked as deleted. This is used - * by various classes to avoid duplication. - * - * MCR migration note: this replaces Revision::userCanBitfield - * - * @param int $bitfield Current field - * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE, - * self::DELETED_COMMENT = File::DELETED_COMMENT, - * self::DELETED_USER = File::DELETED_USER - * @param User $user User object to check - * @param Title|null $title A Title object to check for per-page restrictions on, - * instead of just plain userrights - * @return bool - */ - public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) { - if ( $bitfield & $field ) { // aspect is deleted - if ( $bitfield & self::DELETED_RESTRICTED ) { - $permissions = [ 'suppressrevision', 'viewsuppressed' ]; - } elseif ( $field & self::DELETED_TEXT ) { - $permissions = [ 'deletedtext' ]; - } else { - $permissions = [ 'deletedhistory' ]; - } - $permissionlist = implode( ', ', $permissions ); - if ( $title === null ) { - wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" ); - return $user->isAllowedAny( ...$permissions ); - } else { - $text = $title->getPrefixedText(); - wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" ); - foreach ( $permissions as $perm ) { - if ( $title->userCan( $perm, $user ) ) { - return true; - } - } - return false; - } - } else { - return true; - } - } - - /** - * Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all - * information needed to save it to the database. This should trivially be true for - * RevisionRecords loaded from the database. - * - * Note that this may return true even if getId() or getPage() return null or 0, since these - * are generally assigned while the revision is saved to the database, and may not be available - * before. - * - * @return bool - */ - public function isReadyForInsertion() { - // NOTE: don't check getSize() and getSha1(), since that may cause the full content to - // be loaded in order to calculate the values. Just assume these methods will not return - // null if mSlots is not empty. - - // NOTE: getId() and getPageId() may return null before a revision is saved, so don't - //check them. - - return $this->getTimestamp() !== null - && $this->getComment( self::RAW ) !== null - && $this->getUser( self::RAW ) !== null - && $this->mSlots->getSlotRoles() !== []; - } - -} diff --git a/includes/Storage/RevisionSlots.php b/includes/Storage/RevisionSlots.php deleted file mode 100644 index 91969fc0c7..0000000000 --- a/includes/Storage/RevisionSlots.php +++ /dev/null @@ -1,312 +0,0 @@ -slots = $slots; - } else { - $this->setSlotsInternal( $slots ); - } - } - - /** - * @param SlotRecord[] $slots - */ - private function setSlotsInternal( array $slots ) { - Assert::parameterElementType( SlotRecord::class, $slots, '$slots' ); - - $this->slots = []; - - // re-key the slot array - foreach ( $slots as $slot ) { - $role = $slot->getRole(); - $this->slots[$role] = $slot; - } - } - - /** - * Implemented to defy serialization. - * - * @throws LogicException always - */ - public function __sleep() { - throw new LogicException( __CLASS__ . ' is not serializable.' ); - } - - /** - * Returns the Content of the given slot. - * Call getSlotNames() to get a list of available slots. - * - * Note that for mutable Content objects, each call to this method will return a - * fresh clone. - * - * @param string $role The role name of the desired slot - * - * @throws RevisionAccessException if the slot does not exist or slot data - * could not be lazy-loaded. - * @return Content - */ - public function getContent( $role ) { - // Return a copy to be safe. Immutable content objects return $this from copy(). - return $this->getSlot( $role )->getContent()->copy(); - } - - /** - * Returns the SlotRecord of the given slot. - * Call getSlotNames() to get a list of available slots. - * - * @param string $role The role name of the desired slot - * - * @throws RevisionAccessException if the slot does not exist or slot data - * could not be lazy-loaded. - * @return SlotRecord - */ - public function getSlot( $role ) { - $slots = $this->getSlots(); - - if ( isset( $slots[$role] ) ) { - return $slots[$role]; - } else { - throw new RevisionAccessException( 'No such slot: ' . $role ); - } - } - - /** - * Returns whether the given slot is set. - * - * @param string $role The role name of the desired slot - * - * @return bool - */ - public function hasSlot( $role ) { - $slots = $this->getSlots(); - - return isset( $slots[$role] ); - } - - /** - * Returns the slot names (roles) of all slots present in this revision. - * getContent() will succeed only for the names returned by this method. - * - * @return string[] - */ - public function getSlotRoles() { - $slots = $this->getSlots(); - return array_keys( $slots ); - } - - /** - * Computes the total nominal size of the revision's slots, in bogo-bytes. - * - * @warning This is potentially expensive! It may cause all slot's content to be loaded - * and deserialized. - * - * @return int - */ - public function computeSize() { - return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) { - return $accu + $slot->getSize(); - }, 0 ); - } - - /** - * Returns an associative array that maps role names to SlotRecords. Each SlotRecord - * represents the content meta-data of a slot, together they define the content of - * a revision. - * - * @note This may cause the content meta-data for the revision to be lazy-loaded. - * - * @return SlotRecord[] revision slot/content rows, keyed by slot role name. - */ - public function getSlots() { - if ( is_callable( $this->slots ) ) { - $slots = call_user_func( $this->slots ); - - Assert::postcondition( - is_array( $slots ), - 'Slots info callback should return an array of objects' - ); - - $this->setSlotsInternal( $slots ); - } - - return $this->slots; - } - - /** - * Computes the combined hash of the revisions's slots. - * - * @note For backwards compatibility, the combined hash of a single slot - * is that slot's hash. For consistency, the combined hash of an empty set of slots - * is the hash of the empty string. - * - * @warning This is potentially expensive! It may cause all slot's content to be loaded - * and deserialized, then re-serialized and hashed. - * - * @return string - */ - public function computeSha1() { - $slots = $this->getSlots(); - ksort( $slots ); - - if ( empty( $slots ) ) { - return SlotRecord::base36Sha1( '' ); - } - - return array_reduce( $slots, function ( $accu, SlotRecord $slot ) { - return $accu === null - ? $slot->getSha1() - : SlotRecord::base36Sha1( $accu . $slot->getSha1() ); - }, null ); - } - - /** - * 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 getOriginalSlots() { - return array_filter( - $this->getSlots(), - function ( SlotRecord $slot ) { - return !$slot->isInherited(); - } - ); - } - - /** - * 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 SlotRecord[] - */ - public function getInheritedSlots() { - return array_filter( - $this->getSlots(), - function ( SlotRecord $slot ) { - return $slot->isInherited(); - } - ); - } - - /** - * Checks whether the other RevisionSlots instance has the same content - * as this instance. Note that this does not mean that the slots have to be the same: - * they could for instance belong to different revisions. - * - * @param RevisionSlots $other - * - * @return bool - */ - public function hasSameContent( RevisionSlots $other ) { - if ( $other === $this ) { - return true; - } - - $aSlots = $this->getSlots(); - $bSlots = $other->getSlots(); - - ksort( $aSlots ); - ksort( $bSlots ); - - if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) { - return false; - } - - foreach ( $aSlots as $role => $s ) { - $t = $bSlots[$role]; - - if ( !$s->hasSameContent( $t ) ) { - return false; - } - } - - return true; - } - - /** - * Find roles for which the $other RevisionSlots object has different content - * as this RevisionSlots object, including any roles that are present in one - * but not the other. - * - * @param RevisionSlots $other - * - * @return string[] a list of slot roles that are different. - */ - public function getRolesWithDifferentContent( RevisionSlots $other ) { - if ( $other === $this ) { - return []; - } - - $aSlots = $this->getSlots(); - $bSlots = $other->getSlots(); - - ksort( $aSlots ); - ksort( $bSlots ); - - $different = array_keys( array_merge( - array_diff_key( $aSlots, $bSlots ), - array_diff_key( $bSlots, $aSlots ) - ) ); - - /** @var SlotRecord[] $common */ - $common = array_intersect_key( $aSlots, $bSlots ); - - foreach ( $common as $role => $s ) { - $t = $bSlots[$role]; - - if ( !$s->hasSameContent( $t ) ) { - $different[] = $role; - } - } - - return $different; - } - -} diff --git a/includes/Storage/RevisionSlotsUpdate.php b/includes/Storage/RevisionSlotsUpdate.php index d173a3cc1a..a863ad5a51 100644 --- a/includes/Storage/RevisionSlotsUpdate.php +++ b/includes/Storage/RevisionSlotsUpdate.php @@ -23,6 +23,10 @@ namespace MediaWiki\Storage; use Content; +use MediaWiki\Revision\MutableRevisionSlots; +use MediaWiki\Revision\RevisionAccessException; +use MediaWiki\Revision\RevisionSlots; +use MediaWiki\Revision\SlotRecord; /** * Value object representing a modification of revision slots. diff --git a/includes/Storage/RevisionStore.php b/includes/Storage/RevisionStore.php deleted file mode 100644 index d9db8bd2c2..0000000000 --- a/includes/Storage/RevisionStore.php +++ /dev/null @@ -1,2779 +0,0 @@ -loadBalancer = $loadBalancer; - $this->blobStore = $blobStore; - $this->cache = $cache; - $this->commentStore = $commentStore; - $this->contentModelStore = $contentModelStore; - $this->slotRoleStore = $slotRoleStore; - $this->mcrMigrationStage = $mcrMigrationStage; - $this->actorMigration = $actorMigration; - $this->wikiId = $wikiId; - $this->logger = new NullLogger(); - } - - /** - * @param int $flags A combination of SCHEMA_COMPAT_XXX flags. - * @return bool True if all the given flags were set in the $mcrMigrationStage - * parameter passed to the constructor. - */ - private function hasMcrSchemaFlags( $flags ) { - return ( $this->mcrMigrationStage & $flags ) === $flags; - } - - /** - * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading - * and still reading from the old DB schema. - * - * @throws RevisionAccessException - */ - private function assertCrossWikiContentLoadingIsSafe() { - if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - throw new RevisionAccessException( - "Cross-wiki content loading is not supported by the pre-MCR schema" - ); - } - } - - public function setLogger( LoggerInterface $logger ) { - $this->logger = $logger; - } - - /** - * @return bool Whether the store is read-only - */ - public function isReadOnly() { - return $this->blobStore->isReadOnly(); - } - - /** - * @return bool - */ - public function getContentHandlerUseDB() { - return $this->contentHandlerUseDB; - } - - /** - * @see $wgContentHandlerUseDB - * @param bool $contentHandlerUseDB - * @throws MWException - */ - public function setContentHandlerUseDB( $contentHandlerUseDB ) { - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) - || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) - ) { - if ( !$contentHandlerUseDB ) { - throw new MWException( - 'Content model must be stored in the database for multi content revision migration.' - ); - } - } - $this->contentHandlerUseDB = $contentHandlerUseDB; - } - - /** - * @return ILoadBalancer - */ - private function getDBLoadBalancer() { - return $this->loadBalancer; - } - - /** - * @param int $mode DB_MASTER or DB_REPLICA - * - * @return IDatabase - */ - private function getDBConnection( $mode ) { - $lb = $this->getDBLoadBalancer(); - return $lb->getConnection( $mode, [], $this->wikiId ); - } - - /** - * @param int $queryFlags a bit field composed of READ_XXX flags - * - * @return DBConnRef - */ - private function getDBConnectionRefForQueryFlags( $queryFlags ) { - list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); - return $this->getDBConnectionRef( $mode ); - } - - /** - * @param IDatabase $connection - */ - private function releaseDBConnection( IDatabase $connection ) { - $lb = $this->getDBLoadBalancer(); - $lb->reuseConnection( $connection ); - } - - /** - * @param int $mode DB_MASTER or DB_REPLICA - * - * @return DBConnRef - */ - private function getDBConnectionRef( $mode ) { - $lb = $this->getDBLoadBalancer(); - return $lb->getConnectionRef( $mode, [], $this->wikiId ); - } - - /** - * Determines the page Title based on the available information. - * - * MCR migration note: this corresponds to Revision::getTitle - * - * @note this method should be private, external use should be avoided! - * - * @param int|null $pageId - * @param int|null $revId - * @param int $queryFlags - * - * @return Title - * @throws RevisionAccessException - */ - public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) { - if ( !$pageId && !$revId ) { - throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' ); - } - - // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title - // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method - if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) { - $queryFlags = self::READ_NORMAL; - } - - $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->wikiId === false ); - list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); - $titleFlags = ( $dbMode == DB_MASTER ? Title::GAID_FOR_UPDATE : 0 ); - - // Loading by ID is best, but Title::newFromID does not support that for foreign IDs. - if ( $canUseTitleNewFromId ) { - // TODO: better foreign title handling (introduce TitleFactory) - $title = Title::newFromID( $pageId, $titleFlags ); - if ( $title ) { - return $title; - } - } - - // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. - $canUseRevId = ( $revId !== null && $revId > 0 ); - - if ( $canUseRevId ) { - $dbr = $this->getDBConnectionRef( $dbMode ); - // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that - $row = $dbr->selectRow( - [ 'revision', 'page' ], - [ - 'page_namespace', - 'page_title', - 'page_id', - 'page_latest', - 'page_is_redirect', - 'page_len', - ], - [ 'rev_id' => $revId ], - __METHOD__, - $dbOptions, - [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ] - ); - if ( $row ) { - // TODO: better foreign title handling (introduce TitleFactory) - return Title::newFromRow( $row ); - } - } - - // If we still don't have a title, fallback to master if that wasn't already happening. - if ( $dbMode !== DB_MASTER ) { - $title = $this->getTitle( $pageId, $revId, self::READ_LATEST ); - if ( $title ) { - $this->logger->info( - __METHOD__ . ' fell back to READ_LATEST and got a Title.', - [ 'trace' => wfBacktrace() ] - ); - return $title; - } - } - - throw new RevisionAccessException( - "Could not determine title for page ID $pageId and revision ID $revId" - ); - } - - /** - * @param mixed $value - * @param string $name - * - * @throws IncompleteRevisionException if $value is null - * @return mixed $value, if $value is not null - */ - private function failOnNull( $value, $name ) { - if ( $value === null ) { - throw new IncompleteRevisionException( - "$name must not be " . var_export( $value, true ) . "!" - ); - } - - return $value; - } - - /** - * @param mixed $value - * @param string $name - * - * @throws IncompleteRevisionException if $value is empty - * @return mixed $value, if $value is not null - */ - private function failOnEmpty( $value, $name ) { - if ( $value === null || $value === 0 || $value === '' ) { - throw new IncompleteRevisionException( - "$name must not be " . var_export( $value, true ) . "!" - ); - } - - return $value; - } - - /** - * Insert a new revision into the database, returning the new revision record - * on success and dies horribly on failure. - * - * MCR migration note: this replaces Revision::insertOn - * - * @param RevisionRecord $rev - * @param IDatabase $dbw (master connection) - * - * @throws InvalidArgumentException - * @return RevisionRecord the new revision record. - */ - public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { - // TODO: pass in a DBTransactionContext instead of a database connection. - $this->checkDatabaseWikiId( $dbw ); - - $slotRoles = $rev->getSlotRoles(); - - // Make sure the main slot is always provided throughout migration - if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) { - throw new InvalidArgumentException( - 'main slot must be provided' - ); - } - - // If we are not writing into the new schema, we can't support extra slots. - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) - && $slotRoles !== [ SlotRecord::MAIN ] - ) { - throw new InvalidArgumentException( - 'Only the main slot is supported when not writing to the MCR enabled schema!' - ); - } - - // As long as we are not reading from the new schema, we don't want to write extra slots. - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) - && $slotRoles !== [ SlotRecord::MAIN ] - ) { - throw new InvalidArgumentException( - 'Only the main slot is supported when not reading from the MCR enabled schema!' - ); - } - - // Checks - $this->failOnNull( $rev->getSize(), 'size field' ); - $this->failOnEmpty( $rev->getSha1(), 'sha1 field' ); - $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); - $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); - $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); - $this->failOnNull( $user->getId(), 'user field' ); - $this->failOnEmpty( $user->getName(), 'user_text field' ); - - if ( !$rev->isReadyForInsertion() ) { - // This is here for future-proofing. At the time this check being added, it - // was redundant to the individual checks above. - throw new IncompleteRevisionException( 'Revision is incomplete' ); - } - - // TODO: we shouldn't need an actual Title here. - $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); - $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early - - $parentId = $rev->getParentId() === null - ? $this->getPreviousRevisionId( $dbw, $rev ) - : $rev->getParentId(); - - /** @var RevisionRecord $rev */ - $rev = $dbw->doAtomicSection( - __METHOD__, - function ( IDatabase $dbw, $fname ) use ( - $rev, - $user, - $comment, - $title, - $pageId, - $parentId - ) { - return $this->insertRevisionInternal( - $rev, - $dbw, - $user, - $comment, - $title, - $pageId, - $parentId - ); - } - ); - - // sanity checks - Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' ); - Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' ); - Assert::postcondition( - $rev->getComment( RevisionRecord::RAW ) !== null, - 'revision must have a comment' - ); - Assert::postcondition( - $rev->getUser( RevisionRecord::RAW ) !== null, - 'revision must have a user' - ); - - // Trigger exception if the main slot is missing. - // Technically, this could go away after MCR migration: while - // calling code may require a main slot to exist, RevisionStore - // really should not know or care about that requirement. - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); - - foreach ( $slotRoles as $role ) { - $slot = $rev->getSlot( $role, RevisionRecord::RAW ); - Assert::postcondition( - $slot->getContent() !== null, - $role . ' slot must have content' - ); - Assert::postcondition( - $slot->hasRevision(), - $role . ' slot must have a revision associated' - ); - } - - Hooks::run( 'RevisionRecordInserted', [ $rev ] ); - - // TODO: deprecate in 1.32! - $legacyRevision = new Revision( $rev ); - Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] ); - - return $rev; - } - - private function insertRevisionInternal( - RevisionRecord $rev, - IDatabase $dbw, - User $user, - CommentStoreComment $comment, - Title $title, - $pageId, - $parentId - ) { - $slotRoles = $rev->getSlotRoles(); - - $revisionRow = $this->insertRevisionRowOn( - $dbw, - $rev, - $title, - $parentId - ); - - $revisionId = $revisionRow['rev_id']; - - $blobHints = [ - BlobStore::PAGE_HINT => $pageId, - BlobStore::REVISION_HINT => $revisionId, - BlobStore::PARENT_HINT => $parentId, - ]; - - $newSlots = []; - foreach ( $slotRoles as $role ) { - $slot = $rev->getSlot( $role, RevisionRecord::RAW ); - - // If the SlotRecord already has a revision ID set, this means it already exists - // in the database, and should already belong to the current revision. - // However, a slot may already have a revision, but no content ID, if the slot - // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD - // mode, and the respective archive row was not yet migrated to the new schema. - // In that case, a new slot row (and content row) must be inserted even during - // undeletion. - if ( $slot->hasRevision() && $slot->hasContentId() ) { - // TODO: properly abort transaction if the assertion fails! - Assert::parameter( - $slot->getRevision() === $revisionId, - 'slot role ' . $slot->getRole(), - 'Existing slot should belong to revision ' - . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!' - ); - - // Slot exists, nothing to do, move along. - // This happens when restoring archived revisions. - - $newSlots[$role] = $slot; - - // Write the main slot's text ID to the revision table for backwards compatibility - if ( $slot->getRole() === SlotRecord::MAIN - && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) - ) { - $blobAddress = $slot->getAddress(); - $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress ); - } - } else { - $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints ); - } - } - - $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId ); - - $rev = new RevisionStoreRecord( - $title, - $user, - $comment, - (object)$revisionRow, - new RevisionSlots( $newSlots ), - $this->wikiId - ); - - return $rev; - } - - /** - * @param IDatabase $dbw - * @param int $revisionId - * @param string &$blobAddress (may change!) - * - * @return int the text row id - */ - private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) { - $textId = $this->blobStore->getTextIdFromAddress( $blobAddress ); - if ( !$textId ) { - throw new LogicException( - 'Blob address not supported in 1.29 database schema: ' . $blobAddress - ); - } - - // getTextIdFromAddress() is free to insert something into the text table, so $textId - // may be a new value, not anything already contained in $blobAddress. - $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId ); - - $dbw->update( - 'revision', - [ 'rev_text_id' => $textId ], - [ 'rev_id' => $revisionId ], - __METHOD__ - ); - - return $textId; - } - - /** - * @param IDatabase $dbw - * @param int $revisionId - * @param SlotRecord $protoSlot - * @param Title $title - * @param array $blobHints See the BlobStore::XXX_HINT constants - * @return SlotRecord - */ - private function insertSlotOn( - IDatabase $dbw, - $revisionId, - SlotRecord $protoSlot, - Title $title, - array $blobHints = [] - ) { - if ( $protoSlot->hasAddress() ) { - $blobAddress = $protoSlot->getAddress(); - } else { - $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints ); - } - - $contentId = null; - - // Write the main slot's text ID to the revision table for backwards compatibility - if ( $protoSlot->getRole() === SlotRecord::MAIN - && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) - ) { - // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten - // with the real content ID below. - $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress ); - $contentId = $this->emulateContentId( $textId ); - } - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { - if ( $protoSlot->hasContentId() ) { - $contentId = $protoSlot->getContentId(); - } else { - $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress ); - } - - $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId ); - } - - $savedSlot = SlotRecord::newSaved( - $revisionId, - $contentId, - $blobAddress, - $protoSlot - ); - - return $savedSlot; - } - - /** - * Insert IP revision into ip_changes for use when querying for a range. - * @param IDatabase $dbw - * @param User $user - * @param RevisionRecord $rev - * @param int $revisionId - */ - private function insertIpChangesRow( - IDatabase $dbw, - User $user, - RevisionRecord $rev, - $revisionId - ) { - if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) { - $ipcRow = [ - 'ipc_rev_id' => $revisionId, - 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), - 'ipc_hex' => IP::toHex( $user->getName() ), - ]; - $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); - } - } - - /** - * @param IDatabase $dbw - * @param RevisionRecord $rev - * @param Title $title - * @param int $parentId - * - * @return array a revision table row - * - * @throws MWException - * @throws MWUnknownContentModelException - */ - private function insertRevisionRowOn( - IDatabase $dbw, - RevisionRecord $rev, - Title $title, - $parentId - ) { - $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId ); - - list( $commentFields, $commentCallback ) = - $this->commentStore->insertWithTempTable( - $dbw, - 'rev_comment', - $rev->getComment( RevisionRecord::RAW ) - ); - $revisionRow += $commentFields; - - list( $actorFields, $actorCallback ) = - $this->actorMigration->getInsertValuesWithTempTable( - $dbw, - 'rev_user', - $rev->getUser( RevisionRecord::RAW ) - ); - $revisionRow += $actorFields; - - $dbw->insert( 'revision', $revisionRow, __METHOD__ ); - - if ( !isset( $revisionRow['rev_id'] ) ) { - // only if auto-increment was used - $revisionRow['rev_id'] = intval( $dbw->insertId() ); - - if ( $dbw->getType() === 'mysql' ) { - // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the - // auto-increment value to disk, so on server restart it might reuse IDs from deleted - // revisions. We can fix that with an insert with an explicit rev_id value, if necessary. - - $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) ); - $table = 'archive'; - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { - $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) ); - if ( $maxRevId2 >= $maxRevId ) { - $maxRevId = $maxRevId2; - $table = 'slots'; - } - } - - if ( $maxRevId >= $revisionRow['rev_id'] ) { - $this->logger->debug( - '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.' - . ' Trying to fix it.', - [ - 'revid' => $revisionRow['rev_id'], - 'table' => $table, - 'maxrevid' => $maxRevId, - ] - ); - - if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) { - throw new MWException( 'Failed to get database lock for T202032' ); - } - $fname = __METHOD__; - $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) { - $dbw->unlock( 'fix-for-T202032', $fname ); - } ); - - $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ ); - - // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction - // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing - // inserts too, though, at least on MariaDB 10.1.29. - // - // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent - // transactions in this code path thanks to the row lock from the original ->insert() above. - // - // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning - // that's for non-MySQL DBs. - $row1 = $dbw->query( - $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE' - )->fetchObject(); - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { - $row2 = $dbw->query( - $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ ) - . ' FOR UPDATE' - )->fetchObject(); - } else { - $row2 = null; - } - $maxRevId = max( - $maxRevId, - $row1 ? intval( $row1->v ) : 0, - $row2 ? intval( $row2->v ) : 0 - ); - - // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent - // transactions will throw a duplicate key error here. It doesn't seem worth trying - // to avoid that. - $revisionRow['rev_id'] = $maxRevId + 1; - $dbw->insert( 'revision', $revisionRow, __METHOD__ ); - } - } - } - - $commentCallback( $revisionRow['rev_id'] ); - $actorCallback( $revisionRow['rev_id'], $revisionRow ); - - return $revisionRow; - } - - /** - * @param IDatabase $dbw - * @param RevisionRecord $rev - * @param Title $title - * @param int $parentId - * - * @return array [ 0 => array $revisionRow, 1 => callable ] - * @throws MWException - * @throws MWUnknownContentModelException - */ - private function getBaseRevisionRow( - IDatabase $dbw, - RevisionRecord $rev, - Title $title, - $parentId - ) { - // Record the edit in revisions - $revisionRow = [ - 'rev_page' => $rev->getPageId(), - 'rev_parent_id' => $parentId, - 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, - 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), - 'rev_deleted' => $rev->getVisibility(), - 'rev_len' => $rev->getSize(), - 'rev_sha1' => $rev->getSha1(), - ]; - - if ( $rev->getId() !== null ) { - // Needed to restore revisions with their original ID - $revisionRow['rev_id'] = $rev->getId(); - } - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) { - // In non MCR mode this IF section will relate to the main slot - $mainSlot = $rev->getSlot( SlotRecord::MAIN ); - $model = $mainSlot->getModel(); - $format = $mainSlot->getFormat(); - - // MCR migration note: rev_content_model and rev_content_format will go away - if ( $this->contentHandlerUseDB ) { - $this->assertCrossWikiContentLoadingIsSafe(); - - $defaultModel = ContentHandler::getDefaultModelFor( $title ); - $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat(); - - $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model; - $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format; - } - } - - return $revisionRow; - } - - /** - * @param SlotRecord $slot - * @param Title $title - * @param array $blobHints See the BlobStore::XXX_HINT constants - * - * @throws MWException - * @return string the blob address - */ - private function storeContentBlob( - SlotRecord $slot, - Title $title, - array $blobHints = [] - ) { - $content = $slot->getContent(); - $format = $content->getDefaultFormat(); - $model = $content->getModel(); - - $this->checkContent( $content, $title ); - - return $this->blobStore->storeBlob( - $content->serialize( $format ), - // These hints "leak" some information from the higher abstraction layer to - // low level storage to allow for optimization. - array_merge( - $blobHints, - [ - BlobStore::DESIGNATION_HINT => 'page-content', - BlobStore::ROLE_HINT => $slot->getRole(), - BlobStore::SHA1_HINT => $slot->getSha1(), - BlobStore::MODEL_HINT => $model, - BlobStore::FORMAT_HINT => $format, - ] - ) - ); - } - - /** - * @param SlotRecord $slot - * @param IDatabase $dbw - * @param int $revisionId - * @param int $contentId - */ - private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) { - $slotRow = [ - 'slot_revision_id' => $revisionId, - 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ), - 'slot_content_id' => $contentId, - // If the slot has a specific origin use that ID, otherwise use the ID of the revision - // that we just inserted. - 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId, - ]; - $dbw->insert( 'slots', $slotRow, __METHOD__ ); - } - - /** - * @param SlotRecord $slot - * @param IDatabase $dbw - * @param string $blobAddress - * @return int content row ID - */ - private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) { - $contentRow = [ - 'content_size' => $slot->getSize(), - 'content_sha1' => $slot->getSha1(), - 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ), - 'content_address' => $blobAddress, - ]; - $dbw->insert( 'content', $contentRow, __METHOD__ ); - return intval( $dbw->insertId() ); - } - - /** - * MCR migration note: this corresponds to Revision::checkContentModel - * - * @param Content $content - * @param Title $title - * - * @throws MWException - * @throws MWUnknownContentModelException - */ - private function checkContent( Content $content, Title $title ) { - // Note: may return null for revisions that have not yet been inserted - - $model = $content->getModel(); - $format = $content->getDefaultFormat(); - $handler = $content->getContentHandler(); - - $name = "$title"; - - if ( !$handler->isSupportedFormat( $format ) ) { - throw new MWException( "Can't use format $format with content model $model on $name" ); - } - - if ( !$this->contentHandlerUseDB ) { - // if $wgContentHandlerUseDB is not set, - // all revisions must use the default content model and format. - - $this->assertCrossWikiContentLoadingIsSafe(); - - $defaultModel = ContentHandler::getDefaultModelFor( $title ); - $defaultHandler = ContentHandler::getForModelID( $defaultModel ); - $defaultFormat = $defaultHandler->getDefaultFormat(); - - if ( $model != $defaultModel ) { - throw new MWException( "Can't save non-default content model with " - . "\$wgContentHandlerUseDB disabled: model is $model, " - . "default for $name is $defaultModel" - ); - } - - if ( $format != $defaultFormat ) { - throw new MWException( "Can't use non-default content format with " - . "\$wgContentHandlerUseDB disabled: format is $format, " - . "default for $name is $defaultFormat" - ); - } - } - - if ( !$content->isValid() ) { - throw new MWException( - "New content for $name is not valid! Content model is $model" - ); - } - } - - /** - * Create a new null-revision for insertion into a page's - * history. This will not re-save the text, but simply refer - * to the text from the previous version. - * - * Such revisions can for instance identify page rename - * operations and other such meta-modifications. - * - * @note This method grabs a FOR UPDATE lock on the relevant row of the page table, - * to prevent a new revision from being inserted before the null revision has been written - * to the database. - * - * MCR migration note: this replaces Revision::newNullRevision - * - * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that - * (or go away). - * - * @param IDatabase $dbw used for obtaining the lock on the page table row - * @param Title $title Title of the page to read from - * @param CommentStoreComment $comment RevisionRecord's summary - * @param bool $minor Whether the revision should be considered as minor - * @param User $user The user to attribute the revision to - * - * @return RevisionRecord|null RevisionRecord or null on error - */ - public function newNullRevision( - IDatabase $dbw, - Title $title, - CommentStoreComment $comment, - $minor, - User $user - ) { - $this->checkDatabaseWikiId( $dbw ); - - $pageId = $title->getArticleID(); - - // T51581: Lock the page table row to ensure no other process - // is adding a revision to the page at the same time. - // Avoid locking extra tables, compare T191892. - $pageLatest = $dbw->selectField( - 'page', - 'page_latest', - [ 'page_id' => $pageId ], - __METHOD__, - [ 'FOR UPDATE' ] - ); - - if ( !$pageLatest ) { - return null; - } - - // Fetch the actual revision row from master, without locking all extra tables. - $oldRevision = $this->loadRevisionFromConds( - $dbw, - [ 'rev_id' => intval( $pageLatest ) ], - self::READ_LATEST, - $title - ); - - if ( !$oldRevision ) { - $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId."; - $this->logger->error( - $msg, - [ 'exception' => new RuntimeException( $msg ) ] - ); - return null; - } - - // Construct the new revision - $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing. - $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision ); - - $newRevision->setComment( $comment ); - $newRevision->setUser( $user ); - $newRevision->setTimestamp( $timestamp ); - $newRevision->setMinorEdit( $minor ); - - return $newRevision; - } - - /** - * MCR migration note: this replaces Revision::isUnpatrolled - * - * @todo This is overly specific, so move or kill this method. - * - * @param RevisionRecord $rev - * - * @return int Rcid of the unpatrolled row, zero if there isn't one - */ - public function getRcIdIfUnpatrolled( RevisionRecord $rev ) { - $rc = $this->getRecentChange( $rev ); - if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) { - return $rc->getAttribute( 'rc_id' ); - } else { - return 0; - } - } - - /** - * Get the RC object belonging to the current revision, if there's one - * - * MCR migration note: this replaces Revision::getRecentChange - * - * @todo move this somewhere else? - * - * @param RevisionRecord $rev - * @param int $flags (optional) $flags include: - * IDBAccessObject::READ_LATEST: Select the data from the master - * - * @return null|RecentChange - */ - public function getRecentChange( RevisionRecord $rev, $flags = 0 ) { - list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); - $db = $this->getDBConnection( $dbType ); - - $userIdentity = $rev->getUser( RevisionRecord::RAW ); - - if ( !$userIdentity ) { - // If the revision has no user identity, chances are it never went - // into the database, and doesn't have an RC entry. - return null; - } - - // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that! - $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false ); - $rc = RecentChange::newFromConds( - [ - $actorWhere['conds'], - 'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ), - 'rc_this_oldid' => $rev->getId() - ], - __METHOD__, - $dbType - ); - - $this->releaseDBConnection( $db ); - - // XXX: cache this locally? Glue it to the RevisionRecord? - return $rc; - } - - /** - * Maps fields of the archive row to corresponding revision rows. - * - * @param object $archiveRow - * - * @return object a revision row object, corresponding to $archiveRow. - */ - private static function mapArchiveFields( $archiveRow ) { - $fieldMap = [ - // keep with ar prefix: - 'ar_id' => 'ar_id', - - // not the same suffix: - 'ar_page_id' => 'rev_page', - 'ar_rev_id' => 'rev_id', - - // same suffix: - 'ar_text_id' => 'rev_text_id', - 'ar_timestamp' => 'rev_timestamp', - 'ar_user_text' => 'rev_user_text', - 'ar_user' => 'rev_user', - 'ar_actor' => 'rev_actor', - 'ar_minor_edit' => 'rev_minor_edit', - 'ar_deleted' => 'rev_deleted', - 'ar_len' => 'rev_len', - 'ar_parent_id' => 'rev_parent_id', - 'ar_sha1' => 'rev_sha1', - 'ar_comment' => 'rev_comment', - 'ar_comment_cid' => 'rev_comment_cid', - 'ar_comment_id' => 'rev_comment_id', - 'ar_comment_text' => 'rev_comment_text', - 'ar_comment_data' => 'rev_comment_data', - 'ar_comment_old' => 'rev_comment_old', - 'ar_content_format' => 'rev_content_format', - 'ar_content_model' => 'rev_content_model', - ]; - - $revRow = new stdClass(); - foreach ( $fieldMap as $arKey => $revKey ) { - if ( property_exists( $archiveRow, $arKey ) ) { - $revRow->$revKey = $archiveRow->$arKey; - } - } - - return $revRow; - } - - /** - * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema. - * - * @param object|array $row Either a database row or an array - * @param int $queryFlags for callbacks - * @param Title $title - * - * @return SlotRecord The main slot, extracted from the MW 1.29 style row. - * @throws MWException - */ - private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) { - $mainSlotRow = new stdClass(); - $mainSlotRow->role_name = SlotRecord::MAIN; - $mainSlotRow->model_name = null; - $mainSlotRow->slot_revision_id = null; - $mainSlotRow->slot_content_id = null; - $mainSlotRow->content_address = null; - - $content = null; - $blobData = null; - $blobFlags = null; - - if ( is_object( $row ) ) { - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) { - // Don't emulate from a row when using the new schema. - // Emulating from an array is still OK. - throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' ); - } - - // archive row - if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) { - $row = $this->mapArchiveFields( $row ); - } - - if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) { - $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId( - $row->rev_text_id - ); - } - - // This is used by null-revisions - $mainSlotRow->slot_origin = isset( $row->slot_origin ) - ? intval( $row->slot_origin ) - : null; - - if ( isset( $row->old_text ) ) { - // this happens when the text-table gets joined directly, in the pre-1.30 schema - $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null; - // Check against selects that might have not included old_flags - if ( !property_exists( $row, 'old_flags' ) ) { - throw new InvalidArgumentException( 'old_flags was not set in $row' ); - } - $blobFlags = $row->old_flags ?? ''; - } - - $mainSlotRow->slot_revision_id = intval( $row->rev_id ); - - $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; - $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null; - $mainSlotRow->model_name = isset( $row->rev_content_model ) - ? strval( $row->rev_content_model ) - : null; - // XXX: in the future, we'll probably always use the default format, and drop content_format - $mainSlotRow->format_name = isset( $row->rev_content_format ) - ? strval( $row->rev_content_format ) - : null; - - if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) { - // Overwritten below for SCHEMA_COMPAT_WRITE_NEW - $mainSlotRow->slot_content_id - = $this->emulateContentId( intval( $row->rev_text_id ) ); - } - } elseif ( is_array( $row ) ) { - $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null; - - $mainSlotRow->slot_origin = isset( $row['slot_origin'] ) - ? intval( $row['slot_origin'] ) - : null; - $mainSlotRow->content_address = isset( $row['text_id'] ) - ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) ) - : null; - $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null; - $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; - - $mainSlotRow->model_name = isset( $row['content_model'] ) - ? strval( $row['content_model'] ) : null; // XXX: must be a string! - // XXX: in the future, we'll probably always use the default format, and drop content_format - $mainSlotRow->format_name = isset( $row['content_format'] ) - ? strval( $row['content_format'] ) : null; - $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null; - // XXX: If the flags field is not set then $blobFlags should be null so that no - // decoding will happen. An empty string will result in default decodings. - $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null; - - // if we have a Content object, override mText and mContentModel - if ( !empty( $row['content'] ) ) { - if ( !( $row['content'] instanceof Content ) ) { - throw new MWException( 'content field must contain a Content object.' ); - } - - /** @var Content $content */ - $content = $row['content']; - $handler = $content->getContentHandler(); - - $mainSlotRow->model_name = $content->getModel(); - - // XXX: in the future, we'll probably always use the default format. - if ( $mainSlotRow->format_name === null ) { - $mainSlotRow->format_name = $handler->getDefaultFormat(); - } - } - - if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) { - // Overwritten below for SCHEMA_COMPAT_WRITE_NEW - $mainSlotRow->slot_content_id - = $this->emulateContentId( intval( $row['text_id'] ) ); - } - } else { - throw new MWException( 'Revision constructor passed invalid row format.' ); - } - - // With the old schema, the content changes with every revision, - // except for null-revisions. - if ( !isset( $mainSlotRow->slot_origin ) ) { - $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id; - } - - if ( $mainSlotRow->model_name === null ) { - $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) { - $this->assertCrossWikiContentLoadingIsSafe(); - - // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget! - // TODO: MCR: deprecate $title->getModel(). - return ContentHandler::getDefaultModelFor( $title ); - }; - } - - if ( !$content ) { - // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address - // is missing, but "empty revisions" with no content are used in some edge cases. - - $content = function ( SlotRecord $slot ) - use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow ) - { - return $this->loadSlotContent( - $slot, - $blobData, - $blobFlags, - $mainSlotRow->format_name, - $queryFlags - ); - }; - } - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { - // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing - // the inherited slot to have the same content_id as the original slot. In that case, - // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot. - $mainSlotRow->slot_content_id = - function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) { - $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); - return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN ); - }; - } - - return new SlotRecord( $mainSlotRow, $content ); - } - - /** - * Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode, - * based on the revision's text ID (rev_text_id or ar_text_id, respectively). - * Note that in SCHEMA_COMPAT_WRITE_BOTH, a callback to findSlotContentId() should be used - * instead, since in that mode, some revision rows may already have a real content ID, - * while other's don't - and for the ones that don't, we should indicate that it - * is missing and cause SlotRecords::hasContentId() to return false. - * - * @param int $textId - * @return int The emulated content ID - */ - private function emulateContentId( $textId ) { - // Return a negative number to ensure the ID is distinct from any real content IDs - // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW - // mode. - return -$textId; - } - - /** - * Loads a Content object based on a slot row. - * - * This method does not call $slot->getContent(), and may be used as a callback - * called by $slot->getContent(). - * - * MCR migration note: this roughly corresponds to Revision::getContentInternal - * - * @param SlotRecord $slot The SlotRecord to load content for - * @param string|null $blobData The content blob, in the form indicated by $blobFlags - * @param string|null $blobFlags Flags indicating how $blobData needs to be processed. - * Use null if no processing should happen. That is in constrast to the empty string, - * which causes the blob to be decoded according to the configured legacy encoding. - * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded - * @param int $queryFlags - * - * @throws RevisionAccessException - * @return Content - */ - private function loadSlotContent( - SlotRecord $slot, - $blobData = null, - $blobFlags = null, - $blobFormat = null, - $queryFlags = 0 - ) { - if ( $blobData !== null ) { - Assert::parameterType( 'string', $blobData, '$blobData' ); - Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' ); - - $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null; - - if ( $blobFlags === null ) { - // No blob flags, so use the blob verbatim. - $data = $blobData; - } else { - $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey ); - if ( $data === false ) { - throw new RevisionAccessException( - "Failed to expand blob data using flags $blobFlags (key: $cacheKey)" - ); - } - } - - } else { - $address = $slot->getAddress(); - try { - $data = $this->blobStore->getBlob( $address, $queryFlags ); - } catch ( BlobAccessException $e ) { - throw new RevisionAccessException( - "Failed to load data blob from $address: " . $e->getMessage(), 0, $e - ); - } - } - - // Unserialize content - $handler = ContentHandler::getForModelID( $slot->getModel() ); - - $content = $handler->unserializeContent( $data, $blobFormat ); - return $content; - } - - /** - * Load a page revision from a given revision ID number. - * Returns null if no such revision can be found. - * - * MCR migration note: this replaces Revision::newFromId - * - * $flags include: - * IDBAccessObject::READ_LATEST: Select the data from the master - * IDBAccessObject::READ_LOCKING : Select & lock the data from the master - * - * @param int $id - * @param int $flags (optional) - * @return RevisionRecord|null - */ - public function getRevisionById( $id, $flags = 0 ) { - return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags ); - } - - /** - * Load either the current, or a specified, revision - * that's attached to a given link target. If not attached - * to that link target, will return null. - * - * MCR migration note: this replaces Revision::newFromTitle - * - * $flags include: - * IDBAccessObject::READ_LATEST: Select the data from the master - * IDBAccessObject::READ_LOCKING : Select & lock the data from the master - * - * @param LinkTarget $linkTarget - * @param int $revId (optional) - * @param int $flags Bitfield (optional) - * @return RevisionRecord|null - */ - public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) { - $conds = [ - 'page_namespace' => $linkTarget->getNamespace(), - 'page_title' => $linkTarget->getDBkey() - ]; - if ( $revId ) { - // Use the specified revision ID. - // Note that we use newRevisionFromConds here because we want to retry - // and fall back to master if the page is not found on a replica. - // Since the caller supplied a revision ID, we are pretty sure the revision is - // supposed to exist, so we should try hard to find it. - $conds['rev_id'] = $revId; - return $this->newRevisionFromConds( $conds, $flags ); - } else { - // Use a join to get the latest revision. - // Note that we don't use newRevisionFromConds here because we don't want to retry - // and fall back to master. The assumption is that we only want to force the fallback - // if we are quite sure the revision exists because the caller supplied a revision ID. - // If the page isn't found at all on a replica, it probably simply does not exist. - $db = $this->getDBConnectionRefForQueryFlags( $flags ); - - $conds[] = 'rev_id=page_latest'; - $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); - - return $rev; - } - } - - /** - * Load either the current, or a specified, revision - * that's attached to a given page ID. - * Returns null if no such revision can be found. - * - * MCR migration note: this replaces Revision::newFromPageId - * - * $flags include: - * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20) - * IDBAccessObject::READ_LOCKING : Select & lock the data from the master - * - * @param int $pageId - * @param int $revId (optional) - * @param int $flags Bitfield (optional) - * @return RevisionRecord|null - */ - public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) { - $conds = [ 'page_id' => $pageId ]; - if ( $revId ) { - // Use the specified revision ID. - // Note that we use newRevisionFromConds here because we want to retry - // and fall back to master if the page is not found on a replica. - // Since the caller supplied a revision ID, we are pretty sure the revision is - // supposed to exist, so we should try hard to find it. - $conds['rev_id'] = $revId; - return $this->newRevisionFromConds( $conds, $flags ); - } else { - // Use a join to get the latest revision. - // Note that we don't use newRevisionFromConds here because we don't want to retry - // and fall back to master. The assumption is that we only want to force the fallback - // if we are quite sure the revision exists because the caller supplied a revision ID. - // If the page isn't found at all on a replica, it probably simply does not exist. - $db = $this->getDBConnectionRefForQueryFlags( $flags ); - - $conds[] = 'rev_id=page_latest'; - $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); - - return $rev; - } - } - - /** - * Load the revision for the given title with the given timestamp. - * WARNING: Timestamps may in some circumstances not be unique, - * so this isn't the best key to use. - * - * MCR migration note: this replaces Revision::loadFromTimestamp - * - * @param Title $title - * @param string $timestamp - * @return RevisionRecord|null - */ - public function getRevisionByTimestamp( $title, $timestamp ) { - $db = $this->getDBConnection( DB_REPLICA ); - return $this->newRevisionFromConds( - [ - 'rev_timestamp' => $db->timestamp( $timestamp ), - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ], - 0, - $title - ); - } - - /** - * @param int $revId The revision to load slots for. - * @param int $queryFlags - * - * @return SlotRecord[] - */ - private function loadSlotRecords( $revId, $queryFlags ) { - $revQuery = self::getSlotsQueryInfo( [ 'content' ] ); - - list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); - $db = $this->getDBConnectionRef( $dbMode ); - - $res = $db->select( - $revQuery['tables'], - $revQuery['fields'], - [ - 'slot_revision_id' => $revId, - ], - __METHOD__, - $dbOptions, - $revQuery['joins'] - ); - - $slots = []; - - foreach ( $res as $row ) { - // resolve role names and model names from in-memory cache, instead of joining. - $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id ); - $row->model_name = $this->contentModelStore->getName( (int)$row->content_model ); - - $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags, $row ) { - return $this->loadSlotContent( $slot, null, null, null, $queryFlags ); - }; - - $slots[$row->role_name] = new SlotRecord( $row, $contentCallback ); - } - - if ( !isset( $slots[SlotRecord::MAIN] ) ) { - throw new RevisionAccessException( - 'Main slot of revision ' . $revId . ' not found in database!' - ); - }; - - return $slots; - } - - /** - * Factory method for RevisionSlots. - * - * @note If other code has a need to construct RevisionSlots objects, this should be made - * public, since RevisionSlots instances should not be constructed directly. - * - * @param int $revId - * @param object $revisionRow - * @param int $queryFlags - * @param Title $title - * - * @return RevisionSlots - * @throws MWException - */ - private function newRevisionSlots( - $revId, - $revisionRow, - $queryFlags, - Title $title - ) { - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) { - $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title ); - $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] ); - } else { - // XXX: do we need the same kind of caching here - // that getKnownCurrentRevision uses (if $revId == page_latest?) - - $slots = new RevisionSlots( function () use( $revId, $queryFlags ) { - return $this->loadSlotRecords( $revId, $queryFlags ); - } ); - } - - return $slots; - } - - /** - * Make a fake revision object from an archive table row. This is queried - * for permissions or even inserted (as in Special:Undelete) - * - * MCR migration note: this replaces Revision::newFromArchiveRow - * - * @param object $row - * @param int $queryFlags - * @param Title|null $title - * @param array $overrides associative array with fields of $row to override. This may be - * used e.g. to force the parent revision ID or page ID. Keys in the array are fields - * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to - * override ar_parent_id. - * - * @return RevisionRecord - * @throws MWException - */ - public function newRevisionFromArchiveRow( - $row, - $queryFlags = 0, - Title $title = null, - array $overrides = [] - ) { - Assert::parameterType( 'object', $row, '$row' ); - - // check second argument, since Revision::newFromArchiveRow had $overrides in that spot. - Assert::parameterType( 'integer', $queryFlags, '$queryFlags' ); - - if ( !$title && isset( $overrides['title'] ) ) { - if ( !( $overrides['title'] instanceof Title ) ) { - throw new MWException( 'title field override must contain a Title object.' ); - } - - $title = $overrides['title']; - } - - if ( !isset( $title ) ) { - if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) { - $title = Title::makeTitle( $row->ar_namespace, $row->ar_title ); - } else { - throw new InvalidArgumentException( - 'A Title or ar_namespace and ar_title must be given' - ); - } - } - - foreach ( $overrides as $key => $value ) { - $field = "ar_$key"; - $row->$field = $value; - } - - try { - $user = User::newFromAnyId( - $row->ar_user ?? null, - $row->ar_user_text ?? null, - $row->ar_actor ?? null - ); - } catch ( InvalidArgumentException $ex ) { - wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() ); - $user = new UserIdentityValue( 0, 'Unknown user', 0 ); - } - - $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); - // Legacy because $row may have come from self::selectFields() - $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true ); - - $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $queryFlags, $title ); - - return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); - } - - /** - * @see RevisionFactory::newRevisionFromRow - * - * MCR migration note: this replaces Revision::newFromRow - * - * @param object $row - * @param int $queryFlags - * @param Title|null $title - * - * @return RevisionRecord - */ - public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) { - Assert::parameterType( 'object', $row, '$row' ); - - if ( !$title ) { - $pageId = $row->rev_page ?? 0; // XXX: also check page_id? - $revId = $row->rev_id ?? 0; - - $title = $this->getTitle( $pageId, $revId, $queryFlags ); - } - - if ( !isset( $row->page_latest ) ) { - $row->page_latest = $title->getLatestRevID(); - if ( $row->page_latest === 0 && $title->exists() ) { - wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() ); - } - } - - try { - $user = User::newFromAnyId( - $row->rev_user ?? null, - $row->rev_user_text ?? null, - $row->rev_actor ?? null - ); - } catch ( InvalidArgumentException $ex ) { - wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() ); - $user = new UserIdentityValue( 0, 'Unknown user', 0 ); - } - - $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); - // Legacy because $row may have come from self::selectFields() - $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true ); - - $slots = $this->newRevisionSlots( $row->rev_id, $row, $queryFlags, $title ); - - return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); - } - - /** - * Constructs a new MutableRevisionRecord based on the given associative array following - * the MW1.29 convention for the Revision constructor. - * - * MCR migration note: this replaces Revision::newFromRow - * - * @param array $fields - * @param int $queryFlags - * @param Title|null $title - * - * @return MutableRevisionRecord - * @throws MWException - * @throws RevisionAccessException - */ - public function newMutableRevisionFromArray( - array $fields, - $queryFlags = 0, - Title $title = null - ) { - if ( !$title && isset( $fields['title'] ) ) { - if ( !( $fields['title'] instanceof Title ) ) { - throw new MWException( 'title field must contain a Title object.' ); - } - - $title = $fields['title']; - } - - if ( !$title ) { - $pageId = $fields['page'] ?? 0; - $revId = $fields['id'] ?? 0; - - $title = $this->getTitle( $pageId, $revId, $queryFlags ); - } - - if ( !isset( $fields['page'] ) ) { - $fields['page'] = $title->getArticleID( $queryFlags ); - } - - // if we have a content object, use it to set the model and type - if ( !empty( $fields['content'] ) ) { - if ( !( $fields['content'] instanceof Content ) && !is_array( $fields['content'] ) ) { - throw new MWException( - 'content field must contain a Content object or an array of Content objects.' - ); - } - } - - if ( !empty( $fields['text_id'] ) ) { - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - throw new MWException( "The text_id field is only available in the pre-MCR schema" ); - } - - if ( !empty( $fields['content'] ) ) { - throw new MWException( - "Text already stored in external store (id {$fields['text_id']}), " . - "can't specify content object" - ); - } - } - - if ( - isset( $fields['comment'] ) - && !( $fields['comment'] instanceof CommentStoreComment ) - ) { - $commentData = $fields['comment_data'] ?? null; - - if ( $fields['comment'] instanceof Message ) { - $fields['comment'] = CommentStoreComment::newUnsavedComment( - $fields['comment'], - $commentData - ); - } else { - $commentText = trim( strval( $fields['comment'] ) ); - $fields['comment'] = CommentStoreComment::newUnsavedComment( - $commentText, - $commentData - ); - } - } - - $revision = new MutableRevisionRecord( $title, $this->wikiId ); - $this->initializeMutableRevisionFromArray( $revision, $fields ); - - if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) { - foreach ( $fields['content'] as $role => $content ) { - $revision->setContent( $role, $content ); - } - } else { - $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title ); - $revision->setSlot( $mainSlot ); - } - - return $revision; - } - - /** - * @param MutableRevisionRecord $record - * @param array $fields - */ - private function initializeMutableRevisionFromArray( - MutableRevisionRecord $record, - array $fields - ) { - /** @var UserIdentity $user */ - $user = null; - - if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) { - $user = $fields['user']; - } else { - try { - $user = User::newFromAnyId( - $fields['user'] ?? null, - $fields['user_text'] ?? null, - $fields['actor'] ?? null - ); - } catch ( InvalidArgumentException $ex ) { - $user = null; - } - } - - if ( $user ) { - $record->setUser( $user ); - } - - $timestamp = isset( $fields['timestamp'] ) - ? strval( $fields['timestamp'] ) - : wfTimestampNow(); // TODO: use a callback, so we can override it for testing. - - $record->setTimestamp( $timestamp ); - - if ( isset( $fields['page'] ) ) { - $record->setPageId( intval( $fields['page'] ) ); - } - - if ( isset( $fields['id'] ) ) { - $record->setId( intval( $fields['id'] ) ); - } - if ( isset( $fields['parent_id'] ) ) { - $record->setParentId( intval( $fields['parent_id'] ) ); - } - - if ( isset( $fields['sha1'] ) ) { - $record->setSha1( $fields['sha1'] ); - } - if ( isset( $fields['size'] ) ) { - $record->setSize( intval( $fields['size'] ) ); - } - - if ( isset( $fields['minor_edit'] ) ) { - $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 ); - } - if ( isset( $fields['deleted'] ) ) { - $record->setVisibility( intval( $fields['deleted'] ) ); - } - - if ( isset( $fields['comment'] ) ) { - Assert::parameterType( - CommentStoreComment::class, - $fields['comment'], - '$row[\'comment\']' - ); - $record->setComment( $fields['comment'] ); - } - } - - /** - * Load a page revision from a given revision ID number. - * Returns null if no such revision can be found. - * - * MCR migration note: this corresponds to Revision::loadFromId - * - * @note direct use is deprecated! - * @todo remove when unused! there seem to be no callers of Revision::loadFromId - * - * @param IDatabase $db - * @param int $id - * - * @return RevisionRecord|null - */ - public function loadRevisionFromId( IDatabase $db, $id ) { - return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] ); - } - - /** - * Load either the current, or a specified, revision - * that's attached to a given page. If not attached - * to that page, will return null. - * - * MCR migration note: this replaces Revision::loadFromPageId - * - * @note direct use is deprecated! - * @todo remove when unused! - * - * @param IDatabase $db - * @param int $pageid - * @param int $id - * @return RevisionRecord|null - */ - public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) { - $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ]; - if ( $id ) { - $conds['rev_id'] = intval( $id ); - } else { - $conds[] = 'rev_id=page_latest'; - } - return $this->loadRevisionFromConds( $db, $conds ); - } - - /** - * Load either the current, or a specified, revision - * that's attached to a given page. If not attached - * to that page, will return null. - * - * MCR migration note: this replaces Revision::loadFromTitle - * - * @note direct use is deprecated! - * @todo remove when unused! - * - * @param IDatabase $db - * @param Title $title - * @param int $id - * - * @return RevisionRecord|null - */ - public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) { - if ( $id ) { - $matchId = intval( $id ); - } else { - $matchId = 'page_latest'; - } - - return $this->loadRevisionFromConds( - $db, - [ - "rev_id=$matchId", - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ], - 0, - $title - ); - } - - /** - * Load the revision for the given title with the given timestamp. - * WARNING: Timestamps may in some circumstances not be unique, - * so this isn't the best key to use. - * - * MCR migration note: this replaces Revision::loadFromTimestamp - * - * @note direct use is deprecated! Use getRevisionFromTimestamp instead! - * @todo remove when unused! - * - * @param IDatabase $db - * @param Title $title - * @param string $timestamp - * @return RevisionRecord|null - */ - public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) { - return $this->loadRevisionFromConds( $db, - [ - 'rev_timestamp' => $db->timestamp( $timestamp ), - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ], - 0, - $title - ); - } - - /** - * Given a set of conditions, fetch a revision - * - * This method should be used if we are pretty sure the revision exists. - * Unless $flags has READ_LATEST set, this method will first try to find the revision - * on a replica before hitting the master database. - * - * MCR migration note: this corresponds to Revision::newFromConds - * - * @param array $conditions - * @param int $flags (optional) - * @param Title|null $title - * - * @return RevisionRecord|null - */ - private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) { - $db = $this->getDBConnectionRefForQueryFlags( $flags ); - $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title ); - - $lb = $this->getDBLoadBalancer(); - - // Make sure new pending/committed revision are visibile later on - // within web requests to certain avoid bugs like T93866 and T94407. - if ( !$rev - && !( $flags & self::READ_LATEST ) - && $lb->getServerCount() > 1 - && $lb->hasOrMadeRecentMasterChanges() - ) { - $flags = self::READ_LATEST; - $dbw = $this->getDBConnection( DB_MASTER ); - $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title ); - $this->releaseDBConnection( $dbw ); - } - - return $rev; - } - - /** - * Given a set of conditions, fetch a revision from - * the given database connection. - * - * MCR migration note: this corresponds to Revision::loadFromConds - * - * @param IDatabase $db - * @param array $conditions - * @param int $flags (optional) - * @param Title|null $title - * - * @return RevisionRecord|null - */ - private function loadRevisionFromConds( - IDatabase $db, - $conditions, - $flags = 0, - Title $title = null - ) { - $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags ); - if ( $row ) { - $rev = $this->newRevisionFromRow( $row, $flags, $title ); - - return $rev; - } - - return null; - } - - /** - * Throws an exception if the given database connection does not belong to the wiki this - * RevisionStore is bound to. - * - * @param IDatabase $db - * @throws MWException - */ - private function checkDatabaseWikiId( IDatabase $db ) { - $storeWiki = $this->wikiId; - $dbWiki = $db->getDomainID(); - - if ( $dbWiki === $storeWiki ) { - return; - } - - // XXX: we really want the default database ID... - $storeWiki = $storeWiki ?: wfWikiID(); - $dbWiki = $dbWiki ?: wfWikiID(); - - if ( $dbWiki === $storeWiki ) { - return; - } - - // HACK: counteract encoding imposed by DatabaseDomain - $storeWiki = str_replace( '?h', '-', $storeWiki ); - $dbWiki = str_replace( '?h', '-', $dbWiki ); - - if ( $dbWiki === $storeWiki ) { - return; - } - - throw new MWException( "RevisionStore for $storeWiki " - . "cannot be used with a DB connection for $dbWiki" ); - } - - /** - * Given a set of conditions, return a row with the - * fields necessary to build RevisionRecord objects. - * - * MCR migration note: this corresponds to Revision::fetchFromConds - * - * @param IDatabase $db - * @param array $conditions - * @param int $flags (optional) - * - * @return object|false data row as a raw object - */ - private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) { - $this->checkDatabaseWikiId( $db ); - - $revQuery = $this->getQueryInfo( [ 'page', 'user' ] ); - $options = []; - if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) { - $options[] = 'FOR UPDATE'; - } - return $db->selectRow( - $revQuery['tables'], - $revQuery['fields'], - $conditions, - __METHOD__, - $options, - $revQuery['joins'] - ); - } - - /** - * Finds the ID of a content row for a given revision and slot role. - * This can be used to re-use content rows even while the content ID - * is still missing from SlotRecords, when writing to both the old and - * the new schema during MCR schema migration. - * - * @todo remove after MCR schema migration is complete. - * - * @param IDatabase $db - * @param int $revId - * @param string $role - * - * @return int|null - */ - private function findSlotContentId( IDatabase $db, $revId, $role ) { - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) { - return null; - } - - try { - $roleId = $this->slotRoleStore->getId( $role ); - $conditions = [ - 'slot_revision_id' => $revId, - 'slot_role_id' => $roleId, - ]; - - $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ ); - - return $contentId ?: null; - } catch ( NameTableAccessException $ex ) { - // If the role is missing from the slot_roles table, - // the corresponding row in slots cannot exist. - return null; - } - } - - /** - * Return the tables, fields, and join conditions to be selected to create - * a new RevisionStoreRecord object. - * - * MCR migration note: this replaces Revision::getQueryInfo - * - * If the format of fields returned changes in any way then the cache key provided by - * self::getRevisionRowCacheKey should be updated. - * - * @since 1.31 - * - * @param array $options Any combination of the following strings - * - 'page': Join with the page table, and select fields to identify the page - * - 'user': Join with the user table, and select the user name - * - 'text': Join with the text table, and select fields to load page text. This - * option is deprecated in MW 1.32 when the MCR migration flag SCHEMA_COMPAT_WRITE_NEW - * is set, and disallowed when SCHEMA_COMPAT_READ_OLD is not set. - * - * @return array With three keys: - * - tables: (string[]) to include in the `$table` to `IDatabase->select()` - * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` - * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` - */ - public function getQueryInfo( $options = [] ) { - $ret = [ - 'tables' => [], - 'fields' => [], - 'joins' => [], - ]; - - $ret['tables'][] = 'revision'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'rev_id', - 'rev_page', - 'rev_timestamp', - 'rev_minor_edit', - 'rev_deleted', - 'rev_len', - 'rev_parent_id', - 'rev_sha1', - ] ); - - $commentQuery = $this->commentStore->getJoin( 'rev_comment' ); - $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] ); - $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); - $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); - - $actorQuery = $this->actorMigration->getJoin( 'rev_user' ); - $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] ); - $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] ); - $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] ); - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - $ret['fields'][] = 'rev_text_id'; - - if ( $this->contentHandlerUseDB ) { - $ret['fields'][] = 'rev_content_format'; - $ret['fields'][] = 'rev_content_model'; - } - } - - if ( in_array( 'page', $options, true ) ) { - $ret['tables'][] = 'page'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'page_namespace', - 'page_title', - 'page_id', - 'page_latest', - 'page_is_redirect', - 'page_len', - ] ); - $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ]; - } - - if ( in_array( 'user', $options, true ) ) { - $ret['tables'][] = 'user'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'user_name', - ] ); - $u = $actorQuery['fields']['rev_user']; - $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ]; - } - - if ( in_array( 'text', $options, true ) ) { - if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) { - throw new InvalidArgumentException( 'text table can no longer be joined directly' ); - } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - // NOTE: even when this class is set to not read from the old schema, callers - // should still be able to join against the text table, as long as we are still - // writing the old schema for compatibility. - // TODO: This should trigger a deprecation warning eventually (T200918), but not - // before all known usages are removed (see T198341 and T201164). - // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' ); - } - - $ret['tables'][] = 'text'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'old_text', - 'old_flags' - ] ); - $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ]; - } - - return $ret; - } - - /** - * Return the tables, fields, and join conditions to be selected to create - * a new SlotRecord. - * - * @since 1.32 - * - * @param array $options Any combination of the following strings - * - 'content': Join with the content table, and select content meta-data fields - * - 'model': Join with the content_models table, and select the model_name field. - * Only applicable if 'content' is also set. - * - 'role': Join with the slot_roles table, and select the role_name field - * - * @return array With three keys: - * - tables: (string[]) to include in the `$table` to `IDatabase->select()` - * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` - * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` - */ - public function getSlotsQueryInfo( $options = [] ) { - $ret = [ - 'tables' => [], - 'fields' => [], - 'joins' => [], - ]; - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - $db = $this->getDBConnectionRef( DB_REPLICA ); - $ret['tables']['slots'] = 'revision'; - - $ret['fields']['slot_revision_id'] = 'slots.rev_id'; - $ret['fields']['slot_content_id'] = 'NULL'; - $ret['fields']['slot_origin'] = 'slots.rev_id'; - $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN ); - - if ( in_array( 'content', $options, true ) ) { - $ret['fields']['content_size'] = 'slots.rev_len'; - $ret['fields']['content_sha1'] = 'slots.rev_sha1'; - $ret['fields']['content_address'] - = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ); - - if ( $this->contentHandlerUseDB ) { - $ret['fields']['model_name'] = 'slots.rev_content_model'; - } else { - $ret['fields']['model_name'] = 'NULL'; - } - } - } else { - $ret['tables'][] = 'slots'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'slot_revision_id', - 'slot_content_id', - 'slot_origin', - 'slot_role_id', - ] ); - - if ( in_array( 'role', $options, true ) ) { - // Use left join to attach role name, so we still find the revision row even - // if the role name is missing. This triggers a more obvious failure mode. - $ret['tables'][] = 'slot_roles'; - $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ]; - $ret['fields'][] = 'role_name'; - } - - if ( in_array( 'content', $options, true ) ) { - $ret['tables'][] = 'content'; - $ret['fields'] = array_merge( $ret['fields'], [ - 'content_size', - 'content_sha1', - 'content_address', - 'content_model', - ] ); - $ret['joins']['content'] = [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ]; - - if ( in_array( 'model', $options, true ) ) { - // Use left join to attach model name, so we still find the revision row even - // if the model name is missing. This triggers a more obvious failure mode. - $ret['tables'][] = 'content_models'; - $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ]; - $ret['fields'][] = 'model_name'; - } - - } - } - - return $ret; - } - - /** - * Return the tables, fields, and join conditions to be selected to create - * a new RevisionArchiveRecord object. - * - * MCR migration note: this replaces Revision::getArchiveQueryInfo - * - * @since 1.31 - * - * @return array With three keys: - * - tables: (string[]) to include in the `$table` to `IDatabase->select()` - * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` - * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` - */ - public function getArchiveQueryInfo() { - $commentQuery = $this->commentStore->getJoin( 'ar_comment' ); - $actorQuery = $this->actorMigration->getJoin( 'ar_user' ); - $ret = [ - 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'], - 'fields' => [ - 'ar_id', - 'ar_page_id', - 'ar_namespace', - 'ar_title', - 'ar_rev_id', - 'ar_timestamp', - 'ar_minor_edit', - 'ar_deleted', - 'ar_len', - 'ar_parent_id', - 'ar_sha1', - ] + $commentQuery['fields'] + $actorQuery['fields'], - 'joins' => $commentQuery['joins'] + $actorQuery['joins'], - ]; - - if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) { - $ret['fields'][] = 'ar_text_id'; - - if ( $this->contentHandlerUseDB ) { - $ret['fields'][] = 'ar_content_format'; - $ret['fields'][] = 'ar_content_model'; - } - } - - return $ret; - } - - /** - * Do a batched query for the sizes of a set of revisions. - * - * MCR migration note: this replaces Revision::getParentLengths - * - * @param int[] $revIds - * @return int[] associative array mapping revision IDs from $revIds to the nominal size - * of the corresponding revision. - */ - public function getRevisionSizes( array $revIds ) { - return $this->listRevisionSizes( $this->getDBConnection( DB_REPLICA ), $revIds ); - } - - /** - * Do a batched query for the sizes of a set of revisions. - * - * MCR migration note: this replaces Revision::getParentLengths - * - * @deprecated use RevisionStore::getRevisionSizes instead. - * - * @param IDatabase $db - * @param int[] $revIds - * @return int[] associative array mapping revision IDs from $revIds to the nominal size - * of the corresponding revision. - */ - public function listRevisionSizes( IDatabase $db, array $revIds ) { - $this->checkDatabaseWikiId( $db ); - - $revLens = []; - if ( !$revIds ) { - return $revLens; // empty - } - - $res = $db->select( - 'revision', - [ 'rev_id', 'rev_len' ], - [ 'rev_id' => $revIds ], - __METHOD__ - ); - - foreach ( $res as $row ) { - $revLens[$row->rev_id] = intval( $row->rev_len ); - } - - return $revLens; - } - - /** - * Get previous revision for this title - * - * MCR migration note: this replaces Revision::getPrevious - * - * @param RevisionRecord $rev - * @param Title|null $title if known (optional) - * - * @return RevisionRecord|null - */ - public function getPreviousRevision( RevisionRecord $rev, Title $title = null ) { - if ( $title === null ) { - $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); - } - $prev = $title->getPreviousRevisionID( $rev->getId() ); - if ( $prev ) { - return $this->getRevisionByTitle( $title, $prev ); - } - return null; - } - - /** - * Get next revision for this title - * - * MCR migration note: this replaces Revision::getNext - * - * @param RevisionRecord $rev - * @param Title|null $title if known (optional) - * - * @return RevisionRecord|null - */ - public function getNextRevision( RevisionRecord $rev, Title $title = null ) { - if ( $title === null ) { - $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); - } - $next = $title->getNextRevisionID( $rev->getId() ); - if ( $next ) { - return $this->getRevisionByTitle( $title, $next ); - } - return null; - } - - /** - * Get previous revision Id for this page_id - * This is used to populate rev_parent_id on save - * - * MCR migration note: this corresponds to Revision::getPreviousRevisionId - * - * @param IDatabase $db - * @param RevisionRecord $rev - * - * @return int - */ - private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { - $this->checkDatabaseWikiId( $db ); - - if ( $rev->getPageId() === null ) { - return 0; - } - # Use page_latest if ID is not given - if ( !$rev->getId() ) { - $prevId = $db->selectField( - 'page', 'page_latest', - [ 'page_id' => $rev->getPageId() ], - __METHOD__ - ); - } else { - $prevId = $db->selectField( - 'revision', 'rev_id', - [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ], - __METHOD__, - [ 'ORDER BY' => 'rev_id DESC' ] - ); - } - return intval( $prevId ); - } - - /** - * Get rev_timestamp from rev_id, without loading the rest of the row - * - * MCR migration note: this replaces Revision::getTimestampFromId - * - * @param Title $title - * @param int $id - * @param int $flags - * @return string|bool False if not found - */ - public function getTimestampFromId( $title, $id, $flags = 0 ) { - $db = $this->getDBConnectionRefForQueryFlags( $flags ); - - $conds = [ 'rev_id' => $id ]; - $conds['rev_page'] = $title->getArticleID(); - $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ ); - - return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false; - } - - /** - * Get count of revisions per page...not very efficient - * - * MCR migration note: this replaces Revision::countByPageId - * - * @param IDatabase $db - * @param int $id Page id - * @return int - */ - public function countRevisionsByPageId( IDatabase $db, $id ) { - $this->checkDatabaseWikiId( $db ); - - $row = $db->selectRow( 'revision', - [ 'revCount' => 'COUNT(*)' ], - [ 'rev_page' => $id ], - __METHOD__ - ); - if ( $row ) { - return intval( $row->revCount ); - } - return 0; - } - - /** - * Get count of revisions per page...not very efficient - * - * MCR migration note: this replaces Revision::countByTitle - * - * @param IDatabase $db - * @param Title $title - * @return int - */ - public function countRevisionsByTitle( IDatabase $db, $title ) { - $id = $title->getArticleID(); - if ( $id ) { - return $this->countRevisionsByPageId( $db, $id ); - } - return 0; - } - - /** - * Check if no edits were made by other users since - * the time a user started editing the page. Limit to - * 50 revisions for the sake of performance. - * - * MCR migration note: this replaces Revision::userWasLastToEdit - * - * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression - * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit - * has been deprecated since 1.24. - * - * @param IDatabase $db The Database to perform the check on. - * @param int $pageId The ID of the page in question - * @param int $userId The ID of the user in question - * @param string $since Look at edits since this time - * - * @return bool True if the given user was the only one to edit since the given timestamp - */ - public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { - $this->checkDatabaseWikiId( $db ); - - if ( !$userId ) { - return false; - } - - $revQuery = $this->getQueryInfo(); - $res = $db->select( - $revQuery['tables'], - [ - 'rev_user' => $revQuery['fields']['rev_user'], - ], - [ - 'rev_page' => $pageId, - 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) - ], - __METHOD__, - [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ], - $revQuery['joins'] - ); - foreach ( $res as $row ) { - if ( $row->rev_user != $userId ) { - return false; - } - } - return true; - } - - /** - * Load a revision based on a known page ID and current revision ID from the DB - * - * This method allows for the use of caching, though accessing anything that normally - * requires permission checks (aside from the text) will trigger a small DB lookup. - * - * MCR migration note: this replaces Revision::newKnownCurrent - * - * @param Title $title the associated page title - * @param int $revId current revision of this page. Defaults to $title->getLatestRevID(). - * - * @return RevisionRecord|bool Returns false if missing - */ - public function getKnownCurrentRevision( Title $title, $revId ) { - $db = $this->getDBConnectionRef( DB_REPLICA ); - - $pageId = $title->getArticleID(); - - if ( !$pageId ) { - return false; - } - - if ( !$revId ) { - $revId = $title->getLatestRevID(); - } - - if ( !$revId ) { - wfWarn( - 'No latest revision known for page ' . $title->getPrefixedDBkey() - . ' even though it exists with page ID ' . $pageId - ); - return false; - } - - $row = $this->cache->getWithSetCallback( - // Page/rev IDs passed in from DB to reflect history merges - $this->getRevisionRowCacheKey( $db, $pageId, $revId ), - WANObjectCache::TTL_WEEK, - function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) { - $setOpts += Database::getCacheSetOptions( $db ); - - $conds = [ - 'rev_page' => intval( $pageId ), - 'page_id' => intval( $pageId ), - 'rev_id' => intval( $revId ), - ]; - - $row = $this->fetchRevisionRowFromConds( $db, $conds ); - return $row ?: false; // don't cache negatives - } - ); - - // Reflect revision deletion and user renames - if ( $row ) { - return $this->newRevisionFromRow( $row, 0, $title ); - } else { - return false; - } - } - - /** - * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] ) - * Caching rows without 'page' or 'user' could lead to issues. - * If the format of the rows returned by the query provided by getQueryInfo changes the - * cache key should be updated to avoid conflicts. - * - * @param IDatabase $db - * @param int $pageId - * @param int $revId - * @return string - */ - private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) { - return $this->cache->makeGlobalKey( - self::ROW_CACHE_KEY, - $db->getDomainID(), - $pageId, - $revId - ); - } - - // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc. - -} diff --git a/includes/Storage/RevisionStoreFactory.php b/includes/Storage/RevisionStoreFactory.php deleted file mode 100644 index aaaafc15d1..0000000000 --- a/includes/Storage/RevisionStoreFactory.php +++ /dev/null @@ -1,135 +0,0 @@ -dbLoadBalancerFactory = $dbLoadBalancerFactory; - $this->blobStoreFactory = $blobStoreFactory; - $this->nameTables = $nameTables; - $this->cache = $cache; - $this->commentStore = $commentStore; - $this->actorMigration = $actorMigration; - $this->mcrMigrationStage = $migrationStage; - $this->loggerProvider = $loggerProvider; - $this->contentHandlerUseDB = $contentHandlerUseDB; - } - - /** - * @since 1.32 - * - * @param bool|string $wikiId false for the current domain / wikid - * - * @return RevisionStore for the given wikiId with all necessary services and a logger - */ - public function getRevisionStore( $wikiId = false ) { - Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' ); - - $store = new RevisionStore( - $this->dbLoadBalancerFactory->getMainLB( $wikiId ), - $this->blobStoreFactory->newSqlBlobStore( $wikiId ), - $this->cache, // Pass local cache instance; Leave cache sharing to RevisionStore. - $this->commentStore, - $this->nameTables->getContentModels( $wikiId ), - $this->nameTables->getSlotRoles( $wikiId ), - $this->mcrMigrationStage, - $this->actorMigration, - $wikiId - ); - - $store->setLogger( $this->loggerProvider->getLogger( 'RevisionStore' ) ); - $store->setContentHandlerUseDB( $this->contentHandlerUseDB ); - - return $store; - } -} diff --git a/includes/Storage/RevisionStoreRecord.php b/includes/Storage/RevisionStoreRecord.php deleted file mode 100644 index 6148c44366..0000000000 --- a/includes/Storage/RevisionStoreRecord.php +++ /dev/null @@ -1,219 +0,0 @@ -mId = intval( $row->rev_id ); - $this->mPageId = intval( $row->rev_page ); - $this->mComment = $comment; - - $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp ); - Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); - - $this->mUser = $user; - $this->mMinorEdit = boolval( $row->rev_minor_edit ); - $this->mTimestamp = $timestamp; - $this->mDeleted = intval( $row->rev_deleted ); - - // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null - // indicates that the parent revision is unknown. As per MW 1.31, the database schema - // allows rev_parent_id to be NULL. - $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null; - $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; - $this->mSha1 = !empty( $row->rev_sha1 ) ? $row->rev_sha1 : null; - - // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of - // page_latest may be in limbo during revision creation. In that case, calling - // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title - // object. During page creation, that bad value would be 0. - if ( isset( $row->page_latest ) ) { - $this->mCurrent = ( $row->rev_id == $row->page_latest ); - } - - // sanity check - if ( - $this->mPageId && $this->mTitle->exists() - && $this->mPageId !== $this->mTitle->getArticleID() - ) { - throw new InvalidArgumentException( - 'The given Title does not belong to page ID ' . $this->mPageId . - ' but actually belongs to ' . $this->mTitle->getArticleID() - ); - } - } - - /** - * MCR migration note: this replaces Revision::isCurrent - * - * @return bool - */ - public function isCurrent() { - return $this->mCurrent; - } - - /** - * MCR migration note: this replaces Revision::isDeleted - * - * @param int $field One of DELETED_* bitfield constants - * - * @return bool - */ - public function isDeleted( $field ) { - if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { - // Current revisions of pages cannot have the content hidden. Skipping this - // check is very useful for Parser as it fetches templates using newKnownCurrent(). - // Calling getVisibility() in that case triggers a verification database query. - return false; // no need to check - } - - return parent::isDeleted( $field ); - } - - protected function userCan( $field, User $user ) { - if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { - // Current revisions of pages cannot have the content hidden. Skipping this - // check is very useful for Parser as it fetches templates using newKnownCurrent(). - // Calling getVisibility() in that case triggers a verification database query. - return true; // no need to check - } - - return parent::userCan( $field, $user ); - } - - /** - * @return int The revision id, never null. - */ - public function getId() { - // overwritten just to add a guarantee to the contract - return parent::getId(); - } - - /** - * @throws RevisionAccessException if the size was unknown and could not be calculated. - * @return string The nominal revision size, never null. May be computed on the fly. - */ - public function getSize() { - // If length is null, calculate and remember it (potentially SLOW!). - // This is for compatibility with old database rows that don't have the field set. - if ( $this->mSize === null ) { - $this->mSize = $this->mSlots->computeSize(); - } - - return $this->mSize; - } - - /** - * @throws RevisionAccessException if the hash was unknown and could not be calculated. - * @return string The revision hash, never null. May be computed on the fly. - */ - public function getSha1() { - // If hash is null, calculate it and remember (potentially SLOW!) - // This is for compatibility with old database rows that don't have the field set. - if ( $this->mSha1 === null ) { - $this->mSha1 = $this->mSlots->computeSha1(); - } - - return $this->mSha1; - } - - /** - * @param int $audience - * @param User|null $user - * - * @return UserIdentity The identity of the revision author, null if access is forbidden. - */ - public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { - // overwritten just to add a guarantee to the contract - return parent::getUser( $audience, $user ); - } - - /** - * @param int $audience - * @param User|null $user - * - * @return CommentStoreComment The revision comment, null if access is forbidden. - */ - public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { - // overwritten just to add a guarantee to the contract - return parent::getComment( $audience, $user ); - } - - /** - * @return string timestamp, never null - */ - public function getTimestamp() { - // overwritten just to add a guarantee to the contract - return parent::getTimestamp(); - } - - /** - * @see RevisionStore::isComplete - * - * @return bool always true. - */ - public function isReadyForInsertion() { - return true; - } - -} diff --git a/includes/Storage/SlotRecord.php b/includes/Storage/SlotRecord.php deleted file mode 100644 index ee36d4447c..0000000000 --- a/includes/Storage/SlotRecord.php +++ /dev/null @@ -1,658 +0,0 @@ -row; - - return new SlotRecord( $row, function () { - throw new SuppressedDataException( 'Content suppressed!' ); - } ); - } - - /** - * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields. - * The slot's content cannot be overwritten. - * - * @param SlotRecord $slot - * @param array $overrides - * - * @return SlotRecord - */ - private static function newDerived( SlotRecord $slot, array $overrides = [] ) { - $row = clone $slot->row; - $row->slot_id = null; // never copy the row ID! - - foreach ( $overrides as $key => $value ) { - $row->$key = $value; - } - - return new SlotRecord( $row, $slot->content ); - } - - /** - * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord - * of a previous revision. - * - * Note that a SlotRecord constructed this way are intended as prototypes, - * to be used wit newSaved(). They are incomplete, so some getters such as - * getRevision() will fail. - * - * @param SlotRecord $slot - * - * @return SlotRecord - */ - public static function newInherited( SlotRecord $slot ) { - // Sanity check - we can't inherit from a Slot that's not attached to a revision. - $slot->getRevision(); - $slot->getOrigin(); - $slot->getAddress(); - - // NOTE: slot_origin and content_address are copied from $slot. - return self::newDerived( $slot, [ - 'slot_revision_id' => null, - ] ); - } - - /** - * Constructs a new Slot from a Content object for a new revision. - * This is the preferred way to construct a slot for storing Content that - * resulted from a user edit. The slot is assumed to be not inherited. - * - * Note that a SlotRecord constructed this way are intended as prototypes, - * to be used wit newSaved(). They are incomplete, so some getters such as - * getAddress() will fail. - * - * @param string $role - * @param Content $content - * - * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later. - */ - public static function newUnsaved( $role, Content $content ) { - Assert::parameterType( 'string', $role, '$role' ); - - $row = [ - 'slot_id' => null, // not yet known - 'slot_revision_id' => null, // not yet known - 'slot_origin' => null, // not yet known, will be set in newSaved() - 'content_size' => null, // compute later - 'content_sha1' => null, // compute later - 'slot_content_id' => null, // not yet known, will be set in newSaved() - 'content_address' => null, // not yet known, will be set in newSaved() - 'role_name' => $role, - 'model_name' => $content->getModel(), - ]; - - return new SlotRecord( (object)$row, $content ); - } - - /** - * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete - * proto-slot. This adds information that has only become available during saving, - * particularly the revision ID, content ID and content address. - * - * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id). - * If $protoSlot already has a revision, it must be the same. - * @param int|null $contentId the ID of the row in the content table describing the content - * referenced by $contentAddress (field slot_content_id). - * If $protoSlot already has a content ID, it must be the same. - * @param string $contentAddress the slot's content address (field content_address). - * If $protoSlot already has an address, it must be the same. - * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new - * revision. $protoSlot must have a content address if inherited. - * - * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision. - */ - public static function newSaved( - $revisionId, - $contentId, - $contentAddress, - SlotRecord $protoSlot - ) { - Assert::parameterType( 'integer', $revisionId, '$revisionId' ); - // TODO once migration is over $contentId must be an integer - Assert::parameterType( 'integer|null', $contentId, '$contentId' ); - Assert::parameterType( 'string', $contentAddress, '$contentAddress' ); - - if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) { - throw new LogicException( - "Mismatching revision ID $revisionId: " - . "The slot already belongs to revision {$protoSlot->getRevision()}. " - . "Use SlotRecord::newInherited() to re-use content between revisions." - ); - } - - if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) { - throw new LogicException( - "Mismatching blob address $contentAddress: " - . "The slot already has content at {$protoSlot->getAddress()}." - ); - } - - if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) { - throw new LogicException( - "Mismatching content ID $contentId: " - . "The slot already has content row {$protoSlot->getContentId()} associated." - ); - } - - if ( $protoSlot->isInherited() ) { - if ( !$protoSlot->hasAddress() ) { - throw new InvalidArgumentException( - "An inherited blob should have a content address!" - ); - } - if ( !$protoSlot->hasField( 'slot_origin' ) ) { - throw new InvalidArgumentException( - "A saved inherited slot should have an origin set!" - ); - } - $origin = $protoSlot->getOrigin(); - } else { - $origin = $revisionId; - } - - return self::newDerived( $protoSlot, [ - 'slot_revision_id' => $revisionId, - 'slot_content_id' => $contentId, - 'slot_origin' => $origin, - 'content_address' => $contentAddress, - ] ); - } - - /** - * SlotRecord constructor. - * - * The following fields are supported by the $row parameter: - * - * $row->blob_data - * $row->blob_address - * - * @param object $row A database row composed of fields of the slot and content tables, - * as a raw object. Any field value can be a callback that produces the field value - * given this SlotRecord as a parameter. However, plain strings cannot be used as - * callbacks here, for security reasons. - * @param Content|callable $content The content object associated with the slot, or a - * callback that will return that Content object, given this SlotRecord as a parameter. - */ - public function __construct( $row, $content ) { - Assert::parameterType( 'object', $row, '$row' ); - Assert::parameterType( 'Content|callable', $content, '$content' ); - - Assert::parameter( - property_exists( $row, 'slot_revision_id' ), - '$row->slot_revision_id', - 'must exist' - ); - Assert::parameter( - property_exists( $row, 'slot_content_id' ), - '$row->slot_content_id', - 'must exist' - ); - Assert::parameter( - property_exists( $row, 'content_address' ), - '$row->content_address', - 'must exist' - ); - Assert::parameter( - property_exists( $row, 'model_name' ), - '$row->model_name', - 'must exist' - ); - Assert::parameter( - property_exists( $row, 'slot_origin' ), - '$row->slot_origin', - 'must exist' - ); - Assert::parameter( - !property_exists( $row, 'slot_inherited' ), - '$row->slot_inherited', - 'must not exist' - ); - Assert::parameter( - !property_exists( $row, 'slot_revision' ), - '$row->slot_revision', - 'must not exist' - ); - - $this->row = $row; - $this->content = $content; - } - - /** - * Implemented to defy serialization. - * - * @throws LogicException always - */ - public function __sleep() { - throw new LogicException( __CLASS__ . ' is not serializable.' ); - } - - /** - * Returns the Content of the given slot. - * - * @note This is free to load Content from whatever subsystem is necessary, - * performing potentially expensive operations and triggering I/O-related - * failure modes. - * - * @note This method does not apply audience filtering. - * - * @throws SuppressedDataException if access to the content is not allowed according - * to the audience check performed by RevisionRecord::getSlot(). - * - * @return Content The slot's content. This is a direct reference to the internal instance, - * copy before exposing to application logic! - */ - public function getContent() { - if ( $this->content instanceof Content ) { - return $this->content; - } - - $obj = call_user_func( $this->content, $this ); - - Assert::postcondition( - $obj instanceof Content, - 'Slot content callback should return a Content object' - ); - - $this->content = $obj; - - return $this->content; - } - - /** - * Returns the string value of a data field from the database row supplied to the constructor. - * If the field was set to a callback, that callback is invoked and the result returned. - * - * @param string $name - * - * @throws OutOfBoundsException - * @throws IncompleteRevisionException - * @return mixed Returns the field's value, never null. - */ - private function getField( $name ) { - if ( !isset( $this->row->$name ) ) { - // distinguish between unknown and uninitialized fields - if ( property_exists( $this->row, $name ) ) { - throw new IncompleteRevisionException( 'Uninitialized field: ' . $name ); - } else { - throw new OutOfBoundsException( 'No such field: ' . $name ); - } - } - - $value = $this->row->$name; - - // NOTE: allow callbacks, but don't trust plain string callables from the database! - if ( !is_string( $value ) && is_callable( $value ) ) { - $value = call_user_func( $value, $this ); - $this->setField( $name, $value ); - } - - return $value; - } - - /** - * Returns the string value of a data field from the database row supplied to the constructor. - * - * @param string $name - * - * @throws OutOfBoundsException - * @throws IncompleteRevisionException - * @return string Returns the string value - */ - private function getStringField( $name ) { - return strval( $this->getField( $name ) ); - } - - /** - * Returns the int value of a data field from the database row supplied to the constructor. - * - * @param string $name - * - * @throws OutOfBoundsException - * @throws IncompleteRevisionException - * @return int Returns the int value - */ - private function getIntField( $name ) { - return intval( $this->getField( $name ) ); - } - - /** - * @param string $name - * @return bool whether this record contains the given field - */ - private function hasField( $name ) { - if ( isset( $this->row->$name ) ) { - // if the field is a callback, resolve first, then re-check - if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) { - $this->getField( $name ); - } - } - - return isset( $this->row->$name ); - } - - /** - * Returns the ID of the revision this slot is associated with. - * - * @return int - */ - public function getRevision() { - return $this->getIntField( 'slot_revision_id' ); - } - - /** - * Returns the revision ID of the revision that originated the slot's content. - * - * @return int - */ - public function getOrigin() { - return $this->getIntField( 'slot_origin' ); - } - - /** - * Whether this slot was inherited from an older revision. - * - * If this SlotRecord is already attached to a revision, this returns true - * if the slot's revision of origin is the same as the revision it belongs to. - * - * If this SlotRecord is not yet attached to a revision, this returns true - * if the slot already has an address. - * - * @return bool - */ - public function isInherited() { - if ( $this->hasRevision() ) { - return $this->getRevision() !== $this->getOrigin(); - } else { - return $this->hasAddress(); - } - } - - /** - * Whether this slot has an address. Slots will have an address if their - * content has been stored. While building a new revision, - * SlotRecords will not have an address associated. - * - * @return bool - */ - public function hasAddress() { - return $this->hasField( 'content_address' ); - } - - /** - * Whether this slot has an origin (revision ID that originated the slot's content. - * - * @since 1.32 - * - * @return bool - */ - public function hasOrigin() { - return $this->hasField( 'slot_origin' ); - } - - /** - * Whether this slot has a content ID. Slots will have a content ID if their - * content has been stored in the content table. While building a new revision, - * SlotRecords will not have an ID associated. - * - * Also, during schema migration, hasContentId() may return false when encountering an - * un-migrated database entry in SCHEMA_COMPAT_WRITE_BOTH mode. - * It will however always return true for saved revisions on SCHEMA_COMPAT_READ_NEW mode, - * or without SCHEMA_COMPAT_WRITE_NEW mode. In the latter case, an emulated content ID - * is used, derived from the revision's text ID. - * - * Note that hasContentId() returning false while hasRevision() returns true always - * indicates an unmigrated row in SCHEMA_COMPAT_WRITE_BOTH mode, as described above. - * For an unsaved slot, both these methods would return false. - * - * @since 1.32 - * - * @return bool - */ - public function hasContentId() { - return $this->hasField( 'slot_content_id' ); - } - - /** - * Whether this slot has revision ID associated. Slots will have a revision ID associated - * only if they were loaded as part of an existing revision. While building a new revision, - * Slotrecords will not have a revision ID associated. - * - * @return bool - */ - public function hasRevision() { - return $this->hasField( 'slot_revision_id' ); - } - - /** - * Returns the role of the slot. - * - * @return string - */ - public function getRole() { - return $this->getStringField( 'role_name' ); - } - - /** - * Returns the address of this slot's content. - * This address can be used with BlobStore to load the Content object. - * - * @return string - */ - public function getAddress() { - return $this->getStringField( 'content_address' ); - } - - /** - * Returns the ID of the content meta data row associated with the slot. - * This information should be irrelevant to application logic, it is here to allow - * the construction of a full row for the revision table. - * - * Note that this method may return an emulated value during schema migration in - * SCHEMA_COMPAT_WRITE_OLD mode. See RevisionStore::emulateContentId for more information. - * - * @return int - */ - public function getContentId() { - return $this->getIntField( 'slot_content_id' ); - } - - /** - * Returns the content size - * - * @return int size of the content, in bogo-bytes, as reported by Content::getSize. - */ - public function getSize() { - try { - $size = $this->getIntField( 'content_size' ); - } catch ( IncompleteRevisionException $ex ) { - $size = $this->getContent()->getSize(); - $this->setField( 'content_size', $size ); - } - - return $size; - } - - /** - * Returns the content size - * - * @return string hash of the content. - */ - public function getSha1() { - try { - $sha1 = $this->getStringField( 'content_sha1' ); - } catch ( IncompleteRevisionException $ex ) { - $format = $this->hasField( 'format_name' ) - ? $this->getStringField( 'format_name' ) - : null; - - $data = $this->getContent()->serialize( $format ); - $sha1 = self::base36Sha1( $data ); - $this->setField( 'content_sha1', $sha1 ); - } - - return $sha1; - } - - /** - * Returns the content model. This is the model name that decides - * which ContentHandler is appropriate for interpreting the - * data of the blob referenced by the address returned by getAddress(). - * - * @return string the content model of the content - */ - public function getModel() { - try { - $model = $this->getStringField( 'model_name' ); - } catch ( IncompleteRevisionException $ex ) { - $model = $this->getContent()->getModel(); - $this->setField( 'model_name', $model ); - } - - return $model; - } - - /** - * Returns the blob serialization format as a MIME type. - * - * @note When this method returns null, the caller is expected - * to auto-detect the serialization format, or to rely on - * the default format associated with the content model. - * - * @return string|null - */ - public function getFormat() { - // XXX: we currently do not plan to store the format for each slot! - - if ( $this->hasField( 'format_name' ) ) { - return $this->getStringField( 'format_name' ); - } - - return null; - } - - /** - * @param string $name - * @param string|int|null $value - */ - private function setField( $name, $value ) { - $this->row->$name = $value; - } - - /** - * Get the base 36 SHA-1 value for a string of text - * - * MCR migration note: this replaces Revision::base36Sha1 - * - * @param string $blob - * @return string - */ - public static function base36Sha1( $blob ) { - return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 ); - } - - /** - * Returns true if $other has the same content as this slot. - * The check is performed based on the model, address size, and hash. - * Two slots can have the same content if they use different content addresses, - * but if they have the same address and the same model, they have the same content. - * Two slots can have the same content if they belong to different - * revisions or pages. - * - * Note that hasSameContent() may return false even if Content::equals returns true for - * the content of two slots. This may happen if the two slots have different serializations - * representing equivalent Content. Such false negatives are considered acceptable. Code - * that has to be absolutely sure the Content is really not the same if hasSameContent() - * returns false should call getContent() and compare the Content objects directly. - * - * @since 1.32 - * - * @param SlotRecord $other - * @return bool - */ - public function hasSameContent( SlotRecord $other ) { - if ( $other === $this ) { - return true; - } - - if ( $this->getModel() !== $other->getModel() ) { - return false; - } - - if ( $this->hasAddress() - && $other->hasAddress() - && $this->getAddress() == $other->getAddress() - ) { - return true; - } - - if ( $this->getSize() !== $other->getSize() ) { - return false; - } - - if ( $this->getSha1() !== $other->getSha1() ) { - return false; - } - - return true; - } - -} diff --git a/includes/Storage/SuppressedDataException.php b/includes/Storage/SuppressedDataException.php deleted file mode 100644 index 24f16a6482..0000000000 --- a/includes/Storage/SuppressedDataException.php +++ /dev/null @@ -1,33 +0,0 @@ - "$testDir/phpunit/includes/specials/SpecialPageTestBase.php", 'SpecialPageExecutor' => "$testDir/phpunit/includes/specials/SpecialPageExecutor.php", - # tests/phpunit/includes/Storage - 'MediaWiki\Tests\Storage\McrSchemaDetection' => "$testDir/phpunit/includes/Storage/McrSchemaDetection.php", - 'MediaWiki\Tests\Storage\McrSchemaOverride' => "$testDir/phpunit/includes/Storage/McrSchemaOverride.php", - 'MediaWiki\Tests\Storage\McrWriteBothSchemaOverride' => "$testDir/phpunit/includes/Storage/McrWriteBothSchemaOverride.php", - 'MediaWiki\Tests\Storage\McrReadNewSchemaOverride' => "$testDir/phpunit/includes/Storage/McrReadNewSchemaOverride.php", - 'MediaWiki\Tests\Storage\RevisionSlotsTest' => "$testDir/phpunit/includes/Storage/RevisionSlotsTest.php", - 'MediaWiki\Tests\Storage\RevisionRecordTests' => "$testDir/phpunit/includes/Storage/RevisionRecordTests.php", - 'MediaWiki\Tests\Storage\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Storage/RevisionStoreDbTestBase.php", - 'MediaWiki\Tests\Storage\PreMcrSchemaOverride' => "$testDir/phpunit/includes/Storage/PreMcrSchemaOverride.php", + # tests/phpunit/includes/Revision + 'MediaWiki\Tests\Revision\McrSchemaDetection' => "$testDir/phpunit/includes/Revision/McrSchemaDetection.php", + 'MediaWiki\Tests\Revision\McrSchemaOverride' => "$testDir/phpunit/includes/Revision/McrSchemaOverride.php", + 'MediaWiki\Tests\Revision\McrWriteBothSchemaOverride' => "$testDir/phpunit/includes/Revision/McrWriteBothSchemaOverride.php", + 'MediaWiki\Tests\Revision\McrReadNewSchemaOverride' => "$testDir/phpunit/includes/Revision/McrReadNewSchemaOverride.php", + 'MediaWiki\Tests\Revision\RevisionSlotsTest' => "$testDir/phpunit/includes/Revision/RevisionSlotsTest.php", + 'MediaWiki\Tests\Revision\RevisionRecordTests' => "$testDir/phpunit/includes/Revision/RevisionRecordTests.php", + 'MediaWiki\Tests\Revision\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Revision/RevisionStoreDbTestBase.php", + 'MediaWiki\Tests\Revision\PreMcrSchemaOverride' => "$testDir/phpunit/includes/Revision/PreMcrSchemaOverride.php", # tests/phpunit/languages 'LanguageClassesTestCase' => "$testDir/phpunit/languages/LanguageClassesTestCase.php", diff --git a/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php new file mode 100644 index 0000000000..3efd372e07 --- /dev/null +++ b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php @@ -0,0 +1,146 @@ +getSlotRoles() ); + + // new schema is written + $this->assertSelect( + 'slots', + [ 'count(*)' ], + [ 'slot_revision_id' => $rev->getId() ], + [ [ (string)$numberOfSlots ] ] + ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revQuery = $store->getSlotsQueryInfo( [ 'content' ] ); + + $this->assertSelect( + $revQuery['tables'], + [ 'count(*)' ], + [ + 'slot_revision_id' => $rev->getId(), + ], + [ [ (string)$numberOfSlots ] ], + [], + $revQuery['joins'] + ); + + // Legacy schema is still being written + $this->assertSelect( + [ 'revision', 'text' ], + [ 'count(*)' ], + [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], + [ [ 1 ] ], + [], + [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] + ); + + parent::assertRevisionExistsInDatabase( $rev ); + } + + /** + * @param SlotRecord $a + * @param SlotRecord $b + */ + protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { + parent::assertSameSlotContent( $a, $b ); + + // Assert that the same content ID has been used + $this->assertSame( $a->getContentId(), $b->getContentId() ); + } + + public function provideInsertRevisionOn_successes() { + foreach ( parent::provideInsertRevisionOn_successes() as $case ) { + yield $case; + } + + yield 'Multi-slot revision insertion' => [ + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'aux' => new TextContent( 'Egg' ), + ], + 'page' => true, + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + ]; + } + + public function provideNewNullRevision() { + foreach ( parent::provideNewNullRevision() as $case ) { + yield $case; + } + + yield [ + Title::newFromText( 'UTPage_notAutoCreated' ), + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'aux' => new WikitextContent( 'Omelet' ), + ], + ], + CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ), + ]; + } + + public function testGetQueryInfo_NoSlotDataJoin() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $store->getQueryInfo(); + + // with the new schema enabled, query info should not join the main slot info + $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) ); + $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) ); + } + + public function provideNewMutableRevisionFromArray() { + foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { + yield $case; + } + + yield 'Basic array, multiple roles' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 29, + 'parent_id' => 1, + 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii', + 'comment' => 'Goat Comment!', + 'content' => [ + 'main' => new WikitextContent( 'Söme Cöntent' ), + 'aux' => new TextContent( 'Öther Cöntent' ), + ] + ] + ]; + } + +} diff --git a/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php b/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php new file mode 100644 index 0000000000..fdf2629ea9 --- /dev/null +++ b/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php @@ -0,0 +1,57 @@ + [], + 'drop' => [], + 'create' => [], + 'alter' => [], + ]; + + if ( !$this->hasMcrTables( $db ) ) { + $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' ); + } + + if ( !$this->hasPreMcrFields( $db ) ) { + $overrides['alter'][] = 'revision'; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ ); + } + + return $overrides; + } + +} diff --git a/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php new file mode 100644 index 0000000000..84df200d8f --- /dev/null +++ b/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php @@ -0,0 +1,189 @@ +getSlotRoles() ); + + // new schema is written + $this->assertSelect( + 'slots', + [ 'count(*)' ], + [ 'slot_revision_id' => $rev->getId() ], + [ [ (string)$numberOfSlots ] ] + ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revQuery = $store->getSlotsQueryInfo( [ 'content' ] ); + + $this->assertSelect( + $revQuery['tables'], + [ 'count(*)' ], + [ + 'slot_revision_id' => $rev->getId(), + ], + [ [ (string)$numberOfSlots ] ], + [], + $revQuery['joins'] + ); + + parent::assertRevisionExistsInDatabase( $rev ); + } + + /** + * @param SlotRecord $a + * @param SlotRecord $b + */ + protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { + parent::assertSameSlotContent( $a, $b ); + + // Assert that the same content ID has been used + $this->assertSame( $a->getContentId(), $b->getContentId() ); + } + + public function provideInsertRevisionOn_successes() { + foreach ( parent::provideInsertRevisionOn_successes() as $case ) { + yield $case; + } + + yield 'Multi-slot revision insertion' => [ + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'aux' => new TextContent( 'Egg' ), + ], + 'page' => true, + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + ]; + } + + public function provideNewNullRevision() { + foreach ( parent::provideNewNullRevision() as $case ) { + yield $case; + } + + yield [ + Title::newFromText( 'UTPage_notAutoCreated' ), + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'aux' => new WikitextContent( 'Omelet' ), + ], + ], + CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ), + ]; + } + + public function provideNewMutableRevisionFromArray() { + foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { + yield $case; + } + + yield 'Basic array, multiple roles' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 29, + 'parent_id' => 1, + 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii', + 'comment' => 'Goat Comment!', + 'content' => [ + 'main' => new WikitextContent( 'Söme Cöntent' ), + 'aux' => new TextContent( 'Öther Cöntent' ), + ] + ] + ]; + } + + public function testGetQueryInfo_NoSlotDataJoin() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $store->getQueryInfo(); + + // with the new schema enabled, query info should not join the main slot info + $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) ); + $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + * @covers \MediaWiki\Revision\RevisionStore::insertSlotRowOn + * @covers \MediaWiki\Revision\RevisionStore::insertContentRowOn + */ + public function testInsertRevisionOn_T202032() { + // This test only makes sense for MySQL + if ( $this->db->getType() !== 'mysql' ) { + $this->assertTrue( true ); + return; + } + + // NOTE: must be done before checking MAX(rev_id) + $page = $this->getTestPage(); + + $maxRevId = $this->db->selectField( 'revision', 'MAX(rev_id)' ); + + // Construct a slot row that will conflict with the insertion of the next revision ID, + // to emulate the failure mode described in T202032. Nothing will ever read this row, + // we just need it to trigger a primary key conflict. + $this->db->insert( 'slots', [ + 'slot_revision_id' => $maxRevId + 1, + 'slot_role_id' => 1, + 'slot_content_id' => 0, + 'slot_origin' => 0 + ], __METHOD__ ); + + $rev = new MutableRevisionRecord( $page->getTitle() ); + $rev->setTimestamp( '20180101000000' ); + $rev->setComment( CommentStoreComment::newUnsavedComment( 'test' ) ); + $rev->setUser( $this->getTestUser()->getUser() ); + $rev->setContent( 'main', new WikitextContent( 'Text' ) ); + $rev->setPageId( $page->getId() ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $return = $store->insertRevisionOn( $rev, $this->db ); + + $this->assertSame( $maxRevId + 2, $return->getId() ); + + // is the new revision correct? + $this->assertRevisionCompleteness( $return ); + $this->assertRevisionRecordsEqual( $rev, $return ); + + // can we find it directly in the database? + $this->assertRevisionExistsInDatabase( $return ); + + // can we load it from the store? + $loaded = $store->getRevisionById( $return->getId() ); + $this->assertRevisionCompleteness( $loaded ); + $this->assertRevisionRecordsEqual( $return, $loaded ); + } + +} diff --git a/tests/phpunit/includes/Revision/McrSchemaDetection.php b/tests/phpunit/includes/Revision/McrSchemaDetection.php new file mode 100644 index 0000000000..3831ef2294 --- /dev/null +++ b/tests/phpunit/includes/Revision/McrSchemaDetection.php @@ -0,0 +1,42 @@ +tableExists( 'slots', __METHOD__ ); + } + + /** + * Returns true if pre-MCR fields still exist in the database. + * If yes, the database is compatible with with MIGRATION_OLD mode. + * If hasMcrTables() also returns true, the database supports MIGRATION_WRITE_BOTH mode. + * + * Note that if the database has been updated in MIGRATION_NEW mode, + * the rev_text_id field will be 0 for new revisions. This means that + * in MIGRATION_OLD mode, reading such revisions will fail, even though + * all the necessary fields exist. + * This is not relevant for unit tests, since unit tests reset the database content anyway. + * + * @param IDatabase $db + * @return bool + */ + protected function hasPreMcrFields( IDatabase $db ) { + return $db->fieldExists( 'revision', 'rev_content_model', __METHOD__ ); + } + +} diff --git a/tests/phpunit/includes/Revision/McrSchemaOverride.php b/tests/phpunit/includes/Revision/McrSchemaOverride.php new file mode 100644 index 0000000000..dbd271a40d --- /dev/null +++ b/tests/phpunit/includes/Revision/McrSchemaOverride.php @@ -0,0 +1,59 @@ + [], + 'drop' => [], + 'create' => [], + 'alter' => [], + ]; + + if ( !$this->hasMcrTables( $db ) ) { + $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots.sql' ); + } + + if ( !$this->hasPreMcrFields( $db ) ) { + $overrides['alter'][] = 'revision'; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'drop-pre-mcr-fields', __DIR__ ); + } + + return $overrides; + } + +} diff --git a/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php new file mode 100644 index 0000000000..0385708802 --- /dev/null +++ b/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php @@ -0,0 +1,185 @@ +rev_text_id = (string)$rev->getTextId(); + $row->rev_content_format = (string)$rev->getContentFormat(); + $row->rev_content_model = (string)$rev->getContentModel(); + + return $row; + } + + protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { + // New schema is being written + $this->assertSelect( + 'slots', + [ 'count(*)' ], + [ 'slot_revision_id' => $rev->getId() ], + [ [ '1' ] ] + ); + + $this->assertSelect( + 'content', + [ 'count(*)' ], + [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ], + [ [ '1' ] ] + ); + + // Legacy schema is still being written + $this->assertSelect( + [ 'revision', 'text' ], + [ 'count(*)' ], + [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], + [ [ 1 ] ], + [], + [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] + ); + + parent::assertRevisionExistsInDatabase( $rev ); + } + + /** + * @param SlotRecord $a + * @param SlotRecord $b + */ + protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { + parent::assertSameSlotContent( $a, $b ); + + // Assert that the same content ID has been used + if ( $a->hasContentId() && $b->hasContentId() ) { + $this->assertSame( $a->getContentId(), $b->getContentId() ); + } + } + + public function provideInsertRevisionOn_failures() { + foreach ( parent::provideInsertRevisionOn_failures() as $case ) { + yield $case; + } + + yield 'slot that is not main slot' => [ + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'lalala' => new WikitextContent( 'Duck' ), + ], + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + new InvalidArgumentException( 'Only the main slot is supported' ) + ]; + } + + public function provideNewMutableRevisionFromArray() { + foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { + yield $case; + } + + yield 'Basic array, with page & id' => [ + [ + 'id' => 2, + 'page' => 1, + 'text_id' => 2, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'content_format' => 'text/x-wiki', + 'content_model' => 'wikitext', + ] + ]; + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + */ + public function testInsertRevisionFromArchiveRow_unmigratedArchiveRow() { + // The main purpose of this test is to assert that after reading an archive + // row using the old schema it can be inserted into the revision table, + // and a slot row is created based on slot emulated from the old-style archive row, + // when none such slot row exists yet. + + $title = $this->getTestPage()->getTitle(); + + $this->db->insert( + 'text', + [ 'old_text' => 'Just a test', 'old_flags' => 'utf-8' ], + __METHOD__ + ); + + $textId = $this->db->insertId(); + + $row = (object)[ + 'ar_minor_edit' => '0', + 'ar_user' => '0', + 'ar_user_text' => '127.0.0.1', + 'ar_actor' => null, + 'ar_len' => '11', + 'ar_deleted' => '0', + 'ar_rev_id' => 112277, + 'ar_timestamp' => $this->db->timestamp( '20180101000000' ), + 'ar_sha1' => 'deadbeef', + 'ar_page_id' => $title->getArticleID(), + 'ar_comment_text' => 'just a test', + 'ar_comment_data' => null, + 'ar_comment_cid' => null, + 'ar_content_format' => null, + 'ar_content_model' => null, + 'ts_tags' => null, + 'ar_id' => 17, + 'ar_namespace' => $title->getNamespace(), + 'ar_title' => $title->getDBkey(), + 'ar_text_id' => $textId, + 'ar_parent_id' => 112211, + ]; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $rev = $store->newRevisionFromArchiveRow( $row ); + + // re-insert archived revision + $return = $store->insertRevisionOn( $rev, $this->db ); + + // is the new revision correct? + $this->assertRevisionCompleteness( $return ); + $this->assertRevisionRecordsEqual( $rev, $return ); + + // can we load it from the store? + $loaded = $store->getRevisionById( $return->getId() ); + $this->assertNotNull( $loaded ); + $this->assertRevisionCompleteness( $loaded ); + $this->assertRevisionRecordsEqual( $return, $loaded ); + + // can we find it directly in the database? + $this->assertRevisionExistsInDatabase( $return ); + } + +} diff --git a/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php b/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php new file mode 100644 index 0000000000..4ca7fdbd10 --- /dev/null +++ b/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php @@ -0,0 +1,57 @@ + [], + 'drop' => [], + 'create' => [], + 'alter' => [], + ]; + + if ( !$this->hasMcrTables( $db ) ) { + $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' ); + } + + if ( !$this->hasPreMcrFields( $db ) ) { + $overrides['alter'][] = 'revision'; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ ); + } + + return $overrides; + } + +} diff --git a/tests/phpunit/includes/Revision/MutableRevisionRecordTest.php b/tests/phpunit/includes/Revision/MutableRevisionRecordTest.php new file mode 100644 index 0000000000..060099e2ce --- /dev/null +++ b/tests/phpunit/includes/Revision/MutableRevisionRecordTest.php @@ -0,0 +1,347 @@ +resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $record = new MutableRevisionRecord( $title ); + + if ( isset( $rowOverrides['rev_deleted'] ) ) { + $record->setVisibility( $rowOverrides['rev_deleted'] ); + } + + if ( isset( $rowOverrides['rev_id'] ) ) { + $record->setId( $rowOverrides['rev_id'] ); + } + + if ( isset( $rowOverrides['rev_page'] ) ) { + $record->setPageId( $rowOverrides['rev_page'] ); + } + + $record->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $record->setComment( $comment ); + $record->setUser( $user ); + $record->setTimestamp( '20101010000000' ); + + return $record; + } + + public function provideConstructor() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + yield [ + $title, + 'acmewiki' + ]; + } + + /** + * @dataProvider provideConstructor + * + * @param Title $title + * @param bool $wikiId + */ + public function testConstructorAndGetters( + Title $title, + $wikiId = false + ) { + $rec = new MutableRevisionRecord( $title, $wikiId ); + + $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); + $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' ); + } + + public function provideConstructorFailure() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + yield 'not a wiki id' => [ + $title, + null + ]; + } + + /** + * @dataProvider provideConstructorFailure + * + * @param Title $title + * @param bool $wikiId + */ + public function testConstructorFailure( + Title $title, + $wikiId = false + ) { + $this->setExpectedException( InvalidArgumentException::class ); + new MutableRevisionRecord( $title, $wikiId ); + } + + public function testSetGetId() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertNull( $record->getId() ); + $record->setId( 888 ); + $this->assertSame( 888, $record->getId() ); + } + + public function testSetGetUser() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $user = $this->getTestSysop()->getUser(); + $this->assertNull( $record->getUser() ); + $record->setUser( $user ); + $this->assertSame( $user, $record->getUser() ); + } + + public function testSetGetPageId() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertSame( 0, $record->getPageId() ); + $record->setPageId( 999 ); + $this->assertSame( 999, $record->getPageId() ); + } + + public function testSetGetParentId() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertNull( $record->getParentId() ); + $record->setParentId( 100 ); + $this->assertSame( 100, $record->getParentId() ); + } + + public function testGetMainContentWhenEmpty() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->setExpectedException( RevisionAccessException::class ); + $this->assertNull( $record->getContent( SlotRecord::MAIN ) ); + } + + public function testSetGetMainContent() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $content = new WikitextContent( 'Badger' ); + $record->setContent( SlotRecord::MAIN, $content ); + $this->assertSame( $content, $record->getContent( SlotRecord::MAIN ) ); + } + + public function testGetSlotWhenEmpty() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertFalse( $record->hasSlot( SlotRecord::MAIN ) ); + + $this->setExpectedException( RevisionAccessException::class ); + $record->getSlot( SlotRecord::MAIN ); + } + + public function testSetGetSlot() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $slot = SlotRecord::newUnsaved( + SlotRecord::MAIN, + new WikitextContent( 'x' ) + ); + $record->setSlot( $slot ); + $this->assertTrue( $record->hasSlot( SlotRecord::MAIN ) ); + $this->assertSame( $slot, $record->getSlot( SlotRecord::MAIN ) ); + } + + public function testSetGetMinor() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertFalse( $record->isMinor() ); + $record->setMinorEdit( true ); + $this->assertSame( true, $record->isMinor() ); + } + + public function testSetGetTimestamp() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertNull( $record->getTimestamp() ); + $record->setTimestamp( '20180101010101' ); + $this->assertSame( '20180101010101', $record->getTimestamp() ); + } + + public function testSetGetVisibility() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertSame( 0, $record->getVisibility() ); + $record->setVisibility( RevisionRecord::DELETED_USER ); + $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() ); + } + + public function testSetGetSha1() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() ); + $record->setSha1( 'someHash' ); + $this->assertSame( 'someHash', $record->getSha1() ); + } + + public function testGetSlots() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() ); + } + + public function testSetGetSize() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertSame( 0, $record->getSize() ); + $record->setSize( 775 ); + $this->assertSame( 775, $record->getSize() ); + } + + public function testSetGetComment() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $comment = new CommentStoreComment( 1, 'foo' ); + $this->assertNull( $record->getComment() ); + $record->setComment( $comment ); + $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( SlotRecord::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' ) ); + } + + public function provideNotReadyForInsertion() { + /** @var Title $title */ + $title = $this->getMock( Title::class ); + + /** @var User $user */ + $user = $this->getMock( User::class ); + + /** @var CommentStoreComment $comment */ + $comment = $this->getMockBuilder( CommentStoreComment::class ) + ->disableOriginalConstructor() + ->getMock(); + + $content = new TextContent( 'Test' ); + + $rev = new MutableRevisionRecord( $title ); + yield 'empty' => [ $rev ]; + + $rev = new MutableRevisionRecord( $title ); + $rev->setContent( SlotRecord::MAIN, $content ); + $rev->setUser( $user ); + $rev->setComment( $comment ); + yield 'no timestamp' => [ $rev ]; + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( $user ); + $rev->setComment( $comment ); + $rev->setTimestamp( '20101010000000' ); + yield 'no content' => [ $rev ]; + + $rev = new MutableRevisionRecord( $title ); + $rev->setContent( SlotRecord::MAIN, $content ); + $rev->setComment( $comment ); + $rev->setTimestamp( '20101010000000' ); + yield 'no user' => [ $rev ]; + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( $user ); + $rev->setContent( SlotRecord::MAIN, $content ); + $rev->setTimestamp( '20101010000000' ); + yield 'no comment' => [ $rev ]; + } + + /** + * @dataProvider provideNotReadyForInsertion + */ + public function testNotReadyForInsertion( $rev ) { + $this->assertFalse( $rev->isReadyForInsertion() ); + } +} diff --git a/tests/phpunit/includes/Revision/MutableRevisionSlotsTest.php b/tests/phpunit/includes/Revision/MutableRevisionSlotsTest.php new file mode 100644 index 0000000000..7279e64a6e --- /dev/null +++ b/tests/phpunit/includes/Revision/MutableRevisionSlotsTest.php @@ -0,0 +1,148 @@ + [ + [ 1, 2, 3 ] + ]; + } + + /** + * @dataProvider provideConstructorFailue + * @param $slots + * + * @covers \MediaWiki\Revision\RevisionSlots::__construct + * @covers \MediaWiki\Revision\RevisionSlots::setSlotsInternal + */ + public function testConstructorFailue( $slots ) { + $this->setExpectedException( InvalidArgumentException::class ); + + new MutableRevisionSlots( $slots ); + } + + public function testSetMultipleSlots() { + $slots = new MutableRevisionSlots(); + + $this->assertSame( [], $slots->getSlots() ); + + $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) ); + $slots->setSlot( $slotA ); + $this->assertTrue( $slots->hasSlot( 'some' ) ); + $this->assertSame( $slotA, $slots->getSlot( 'some' ) ); + $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() ); + + $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) ); + $slots->setSlot( $slotB ); + $this->assertTrue( $slots->hasSlot( 'other' ) ); + $this->assertSame( $slotB, $slots->getSlot( 'other' ) ); + $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() ); + } + + public function testSetExistingSlotOverwritesSlot() { + $slots = new MutableRevisionSlots(); + + $this->assertSame( [], $slots->getSlots() ); + + $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $slots->setSlot( $slotA ); + $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) ); + $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); + + $slotB = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'B' ) ); + $slots->setSlot( $slotB ); + $this->assertSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) ); + $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( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $slots->setSlot( $slotA ); + $slotB = $this->newSavedSlot( SlotRecord::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( SlotRecord::MAIN ) ); + $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) ); + $this->assertTrue( $slots->getSlot( SlotRecord::MAIN )->isInherited() ); + $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() ); + $this->assertSame( $slotB->getContent(), $slots->getSlot( SlotRecord::MAIN )->getContent() ); + $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() ); + } + + public function testSetContentOfExistingSlotOverwritesContent() { + $slots = new MutableRevisionSlots(); + + $this->assertSame( [], $slots->getSlots() ); + + $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $slots->setSlot( $slotA ); + $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) ); + $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); + + $newContent = new WikitextContent( 'B' ); + $slots->setContent( SlotRecord::MAIN, $newContent ); + $this->assertSame( $newContent, $slots->getContent( SlotRecord::MAIN ) ); + } + + public function testRemoveExistingSlot() { + $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $slots = new MutableRevisionSlots( [ $slotA ] ); + + $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); + + $slots->removeSlot( SlotRecord::MAIN ); + $this->assertSame( [], $slots->getSlots() ); + $this->setExpectedException( RevisionAccessException::class ); + $slots->getSlot( SlotRecord::MAIN ); + } + + public function testNewFromParentRevisionSlots() { + /** @var SlotRecord[] $parentSlots */ + $parentSlots = [ + 'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ), + 'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ), + ]; + $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots ); + $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() ); + $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) ); + $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) ); + $this->assertTrue( $slots->getSlot( 'some' )->isInherited() ); + $this->assertTrue( $slots->getSlot( 'other' )->isInherited() ); + $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) ); + $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) ); + } + +} diff --git a/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php new file mode 100644 index 0000000000..59481f087b --- /dev/null +++ b/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php @@ -0,0 +1,191 @@ +rev_text_id = (string)$rev->getTextId(); + + return $row; + } + + public function provideGetArchiveQueryInfo() { + yield [ + [ + 'tables' => [ 'archive' ], + 'fields' => array_merge( + $this->getDefaultArchiveFields(), + [ + 'ar_comment_text' => 'ar_comment', + 'ar_comment_data' => 'NULL', + 'ar_comment_cid' => 'NULL', + 'ar_user_text' => 'ar_user_text', + 'ar_user' => 'ar_user', + 'ar_actor' => 'NULL', + ] + ), + 'joins' => [], + ] + ]; + } + + public function provideGetQueryInfo() { + yield [ + [], + [ + 'tables' => [ 'revision' ], + 'fields' => array_merge( + $this->getDefaultQueryFields(), + $this->getCommentQueryFields(), + $this->getActorQueryFields() + ), + 'joins' => [], + ] + ]; + yield [ + [ 'page' ], + [ + 'tables' => [ 'revision', 'page' ], + 'fields' => array_merge( + $this->getDefaultQueryFields(), + $this->getCommentQueryFields(), + $this->getActorQueryFields(), + [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ] + ), + 'joins' => [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + ], + ] + ]; + yield [ + [ 'user' ], + [ + 'tables' => [ 'revision', 'user' ], + 'fields' => array_merge( + $this->getDefaultQueryFields(), + $this->getCommentQueryFields(), + $this->getActorQueryFields(), + [ + 'user_name', + ] + ), + 'joins' => [ + 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], + ], + ] + ]; + yield [ + [ 'text' ], + [ + 'tables' => [ 'revision', 'text' ], + 'fields' => array_merge( + $this->getDefaultQueryFields(), + $this->getCommentQueryFields(), + $this->getActorQueryFields(), + [ + 'old_text', + 'old_flags', + ] + ), + 'joins' => [ + 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ], + ], + ] + ]; + } + + public function provideGetSlotsQueryInfo() { + $db = wfGetDB( DB_REPLICA ); + + yield [ + [], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + ] + ), + 'joins' => [], + ] + ]; + yield [ + [ 'content' ], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + 'content_size' => 'slots.rev_len', + 'content_sha1' => 'slots.rev_sha1', + 'content_address' => + $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), + 'model_name' => 'NULL', + ] + ), + 'joins' => [], + ] + ]; + } + + public function provideNewMutableRevisionFromArray() { + foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { + yield $case; + } + + yield 'Basic array, with page & id' => [ + [ + 'id' => 2, + 'page' => 1, + 'text_id' => 2, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + ] + ]; + } + +} diff --git a/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php new file mode 100644 index 0000000000..43453353bd --- /dev/null +++ b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php @@ -0,0 +1,91 @@ +rev_text_id = (string)$rev->getTextId(); + $row->rev_content_format = (string)$rev->getContentFormat(); + $row->rev_content_model = (string)$rev->getContentModel(); + + return $row; + } + + protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { + // Legacy schema is still being written + $this->assertSelect( + [ 'revision', 'text' ], + [ 'count(*)' ], + [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], + [ [ 1 ] ], + [], + [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] + ); + + parent::assertRevisionExistsInDatabase( $rev ); + } + + public function provideInsertRevisionOn_failures() { + foreach ( parent::provideInsertRevisionOn_failures() as $case ) { + yield $case; + } + + yield 'slot that is not main slot' => [ + [ + 'content' => [ + 'main' => new WikitextContent( 'Chicken' ), + 'lalala' => new WikitextContent( 'Duck' ), + ], + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + new InvalidArgumentException( 'Only the main slot is supported' ) + ]; + } + + public function provideNewMutableRevisionFromArray() { + foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { + yield $case; + } + + yield 'Basic array, with page & id' => [ + [ + 'id' => 2, + 'page' => 1, + 'text_id' => 2, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'content_format' => 'text/x-wiki', + 'content_model' => 'wikitext', + ] + ]; + } + +} diff --git a/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php b/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php new file mode 100644 index 0000000000..fb7ef77b47 --- /dev/null +++ b/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php @@ -0,0 +1,53 @@ + [], + 'drop' => [], + 'create' => [], + 'alter' => [], + ]; + + if ( $this->hasMcrTables( $db ) ) { + $overrides['drop'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/drop-mcr-tables', __DIR__ ); + } + + if ( !$this->hasPreMcrFields( $db ) ) { + $overrides['alter'][] = 'revision'; + $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/create-pre-mcr-fields', __DIR__ ); + } + + return $overrides; + } + +} diff --git a/tests/phpunit/includes/Revision/RenderedRevisionTest.php b/tests/phpunit/includes/Revision/RenderedRevisionTest.php index 2ee1ab4995..08a8fa6dd3 100644 --- a/tests/phpunit/includes/Revision/RenderedRevisionTest.php +++ b/tests/phpunit/includes/Revision/RenderedRevisionTest.php @@ -4,17 +4,17 @@ namespace MediaWiki\Tests\Revision; use Content; use Language; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\MutableRevisionSlots; use MediaWiki\Revision\RenderedRevision; -use MediaWiki\Storage\MutableRevisionRecord; -use MediaWiki\Storage\MutableRevisionSlots; -use MediaWiki\Storage\RevisionArchiveRecord; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\RevisionStore; -use MediaWiki\Storage\RevisionStoreRecord; -use MediaWiki\Storage\SlotRecord; -use MediaWiki\Storage\SuppressedDataException; -use MediaWiki\User\UserIdentityValue; +use MediaWiki\Revision\RevisionArchiveRecord; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\RevisionStore; +use MediaWiki\Revision\RevisionStoreRecord; +use MediaWiki\Revision\SlotRecord; +use MediaWiki\Revision\SuppressedDataException; use MediaWikiTestCase; +use MediaWiki\User\UserIdentityValue; use ParserOptions; use ParserOutput; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/phpunit/includes/Revision/RevisionArchiveRecordTest.php b/tests/phpunit/includes/Revision/RevisionArchiveRecordTest.php new file mode 100644 index 0000000000..6262642c1b --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionArchiveRecordTest.php @@ -0,0 +1,272 @@ +resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $row = [ + 'ar_id' => '5', + 'ar_rev_id' => '7', + 'ar_page_id' => strval( $title->getArticleID() ), + 'ar_timestamp' => '20200101000000', + 'ar_deleted' => 0, + 'ar_minor_edit' => 0, + 'ar_parent_id' => '5', + 'ar_len' => $slots->computeSize(), + 'ar_sha1' => $slots->computeSha1(), + ]; + + foreach ( $rowOverrides as $field => $value ) { + $field = preg_replace( '/^rev_/', 'ar_', $field ); + $row[$field] = $value; + } + + return new RevisionArchiveRecord( $title, $user, $comment, (object)$row, $slots ); + } + + public function provideConstructor() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $protoRow = [ + 'ar_id' => '5', + 'ar_rev_id' => '7', + 'ar_page_id' => strval( $title->getArticleID() ), + 'ar_timestamp' => '20200101000000', + 'ar_deleted' => 0, + 'ar_minor_edit' => 0, + 'ar_parent_id' => '5', + 'ar_len' => $slots->computeSize(), + 'ar_sha1' => $slots->computeSha1(), + ]; + + $row = $protoRow; + yield 'all info' => [ + $title, + $user, + $comment, + (object)$row, + $slots, + 'acmewiki' + ]; + + $row = $protoRow; + $row['ar_minor_edit'] = '1'; + $row['ar_deleted'] = strval( RevisionRecord::DELETED_USER ); + + yield 'minor deleted' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + unset( $row['ar_parent'] ); + + yield 'no parent' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + $row['ar_len'] = null; + $row['ar_sha1'] = ''; + + yield 'ar_len is null, ar_sha1 is ""' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + yield 'no length, no hash' => [ + Title::newFromText( 'DummyDoesNotExist' ), + $user, + $comment, + (object)$row, + $slots + ]; + } + + /** + * @dataProvider provideConstructor + * + * @param Title $title + * @param UserIdentity $user + * @param CommentStoreComment $comment + * @param object $row + * @param RevisionSlots $slots + * @param bool $wikiId + */ + public function testConstructorAndGetters( + Title $title, + UserIdentity $user, + CommentStoreComment $comment, + $row, + RevisionSlots $slots, + $wikiId = false + ) { + $rec = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId ); + + $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); + $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' ); + $this->assertSame( $comment, $rec->getComment(), 'getComment' ); + + $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' ); + $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' ); + + $this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' ); + $this->assertSame( (int)$row->ar_rev_id, $rec->getId(), 'getId' ); + $this->assertSame( (int)$row->ar_page_id, $rec->getPageId(), 'getId' ); + $this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' ); + $this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' ); + $this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' ); + + if ( isset( $row->ar_parent_id ) ) { + $this->assertSame( (int)$row->ar_parent_id, $rec->getParentId(), 'getParentId' ); + } else { + $this->assertSame( 0, $rec->getParentId(), 'getParentId' ); + } + + if ( isset( $row->ar_len ) ) { + $this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' ); + } else { + $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' ); + } + + if ( !empty( $row->ar_sha1 ) ) { + $this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' ); + } else { + $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' ); + } + } + + public function provideConstructorFailure() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $protoRow = [ + 'ar_id' => '5', + 'ar_rev_id' => '7', + 'ar_page_id' => strval( $title->getArticleID() ), + 'ar_timestamp' => '20200101000000', + 'ar_deleted' => 0, + 'ar_minor_edit' => 0, + 'ar_parent_id' => '5', + 'ar_len' => $slots->computeSize(), + 'ar_sha1' => $slots->computeSha1(), + ]; + + yield 'not a row' => [ + $title, + $user, + $comment, + 'not a row', + $slots, + 'acmewiki' + ]; + + $row = $protoRow; + $row['ar_timestamp'] = 'kittens'; + + yield 'bad timestamp' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + + yield 'bad wiki' => [ + $title, + $user, + $comment, + (object)$row, + $slots, + 12345 + ]; + + // NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases! + } + + /** + * @dataProvider provideConstructorFailure + * + * @param Title $title + * @param UserIdentity $user + * @param CommentStoreComment $comment + * @param object $row + * @param RevisionSlots $slots + * @param bool $wikiId + */ + public function testConstructorFailure( + Title $title, + UserIdentity $user, + CommentStoreComment $comment, + $row, + RevisionSlots $slots, + $wikiId = false + ) { + $this->setExpectedException( InvalidArgumentException::class ); + new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php b/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php new file mode 100644 index 0000000000..e852bec1f4 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php @@ -0,0 +1,1178 @@ + "{$prefix}_comment", + "{$prefix}_comment_data" => 'NULL', + "{$prefix}_comment_cid" => 'NULL', + ]; + } + + protected function getCompatCommentQueryFields( $prefix ) { + return [ + "{$prefix}_comment_text" + => "COALESCE( comment_{$prefix}_comment.comment_text, {$prefix}_comment )", + "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data", + "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id", + ]; + } + + protected function getNewCommentQueryFields( $prefix ) { + return [ + "{$prefix}_comment_text" => "comment_{$prefix}_comment.comment_text", + "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data", + "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id", + ]; + } + + protected function getOldActorQueryFields( $prefix ) { + return [ + "{$prefix}_user" => "{$prefix}_user", + "{$prefix}_user_text" => "{$prefix}_user_text", + "{$prefix}_actor" => 'NULL', + ]; + } + + protected function getNewActorQueryFields( $prefix, $tmp = false ) { + return [ + "{$prefix}_user" => "actor_{$prefix}_user.actor_user", + "{$prefix}_user_text" => "actor_{$prefix}_user.actor_name", + "{$prefix}_actor" => $tmp ?: "{$prefix}_actor", + ]; + } + + protected function getCompatActorQueryFields( $prefix, $tmp = false ) { + return [ + "{$prefix}_user" => "COALESCE( actor_{$prefix}_user.actor_user, {$prefix}_user )", + "{$prefix}_user_text" => "COALESCE( actor_{$prefix}_user.actor_name, {$prefix}_user_text )", + "{$prefix}_actor" => $tmp ?: "{$prefix}_actor", + ]; + } + + protected function getCompatActorJoins( $prefix ) { + return [ + "temp_{$prefix}_user" => [ + "LEFT JOIN", + "temp_{$prefix}_user.revactor_{$prefix} = {$prefix}_id", + ], + "actor_{$prefix}_user" => [ + "LEFT JOIN", + "actor_{$prefix}_user.actor_id = temp_{$prefix}_user.revactor_actor", + ], + ]; + } + + protected function getCompatCommentJoins( $prefix ) { + return [ + "temp_{$prefix}_comment" => [ + "LEFT JOIN", + "temp_{$prefix}_comment.revcomment_{$prefix} = {$prefix}_id", + ], + "comment_{$prefix}_comment" => [ + "LEFT JOIN", + "comment_{$prefix}_comment.comment_id = temp_{$prefix}_comment.revcomment_comment_id", + ], + ]; + } + + protected function getTextQueryFields() { + return [ + 'old_text', + 'old_flags', + ]; + } + + protected function getPageQueryFields() { + return [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ]; + } + + protected function getUserQueryFields() { + return [ + 'user_name', + ]; + } + + protected function getContentHandlerQueryFields( $prefix ) { + return [ + "{$prefix}_content_format", + "{$prefix}_content_model", + ]; + } + + public function provideArchiveQueryInfo() { + yield 'MCR, comment, actor' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW, + 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW, + ], + [ + 'tables' => [ + 'archive', + 'actor_ar_user' => 'actor', + 'comment_ar_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getArchiveQueryFields( false ), + $this->getNewActorQueryFields( 'ar' ), + $this->getNewCommentQueryFields( 'ar' ) + ), + 'joins' => [ + 'comment_ar_comment' + => [ 'JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], + 'actor_ar_user' => [ 'JOIN', 'actor_ar_user.actor_id = ar_actor' ], + ], + ] + ]; + yield 'read-new MCR, comment, actor' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + ], + [ + 'tables' => [ + 'archive', + 'actor_ar_user' => 'actor', + 'comment_ar_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getArchiveQueryFields( false ), + $this->getCompatActorQueryFields( 'ar' ), + $this->getCompatCommentQueryFields( 'ar' ) + ), + 'joins' => [ + 'comment_ar_comment' + => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], + 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ], + ], + ] + ]; + yield 'MCR write-both/read-old' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + ], + [ + 'tables' => [ + 'archive', + 'actor_ar_user' => 'actor', + 'comment_ar_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getArchiveQueryFields( true ), + $this->getContentHandlerQueryFields( 'ar' ), + $this->getCompatActorQueryFields( 'ar' ), + $this->getCompatCommentQueryFields( 'ar' ) + ), + 'joins' => [ + 'comment_ar_comment' + => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], + 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ], + ], + ] + ]; + yield 'pre-MCR, no model' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ + 'tables' => [ + 'archive', + ], + 'fields' => array_merge( + $this->getArchiveQueryFields( true ), + $this->getOldActorQueryFields( 'ar' ), + $this->getOldCommentQueryFields( 'ar' ) + ), + 'joins' => [], + ] + ]; + } + + public function provideQueryInfo() { + // TODO: more option variations + yield 'MCR, page, user, comment, actor' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW, + 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW, + ], + [ 'page', 'user' ], + [ + 'tables' => [ + 'revision', + 'page', + 'user', + 'temp_rev_user' => 'revision_actor_temp', + 'temp_rev_comment' => 'revision_comment_temp', + 'actor_rev_user' => 'actor', + 'comment_rev_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( false ), + $this->getPageQueryFields(), + $this->getUserQueryFields(), + $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), + $this->getNewCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + 'user' => [ + 'LEFT JOIN', + [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ], + ], + 'comment_rev_comment' => [ + 'JOIN', + 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id', + ], + 'actor_rev_user' => [ + 'JOIN', + 'actor_rev_user.actor_id = temp_rev_user.revactor_actor', + ], + 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + 'temp_rev_comment' => [ 'JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ], + ], + ] + ]; + yield 'MCR read-new, page, user, comment, actor' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + ], + [ 'page', 'user' ], + [ + 'tables' => [ + 'revision', + 'page', + 'user', + 'temp_rev_user' => 'revision_actor_temp', + 'temp_rev_comment' => 'revision_comment_temp', + 'actor_rev_user' => 'actor', + 'comment_rev_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( false ), + $this->getPageQueryFields(), + $this->getUserQueryFields(), + $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), + $this->getCompatCommentQueryFields( 'rev' ) + ), + 'joins' => array_merge( + [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + 'user' => [ + 'LEFT JOIN', + [ + 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', + 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' + ] + ], + ], + $this->getCompatActorJoins( 'rev' ), + $this->getCompatCommentJoins( 'rev' ) + ), + ] + ]; + yield 'MCR read-new' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, + ], + [ 'page', 'user' ], + [ + 'tables' => [ + 'revision', + 'page', + 'user', + 'temp_rev_user' => 'revision_actor_temp', + 'temp_rev_comment' => 'revision_comment_temp', + 'actor_rev_user' => 'actor', + 'comment_rev_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( false ), + $this->getPageQueryFields(), + $this->getUserQueryFields(), + $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), + $this->getCompatCommentQueryFields( 'rev' ) + ), + 'joins' => array_merge( + [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + 'user' => [ + 'LEFT JOIN', + [ + 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', + 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' + ] + ], + ], + $this->getCompatActorJoins( 'rev' ), + $this->getCompatCommentJoins( 'rev' ) + ), + ] + ]; + yield 'MCR write-both/read-old' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + ], + [], + [ + 'tables' => [ + 'revision', + 'temp_rev_user' => 'revision_actor_temp', + 'temp_rev_comment' => 'revision_comment_temp', + 'actor_rev_user' => 'actor', + 'comment_rev_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getContentHandlerQueryFields( 'rev' ), + $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), + $this->getCompatCommentQueryFields( 'rev' ) + ), + 'joins' => array_merge( + $this->getCompatActorJoins( 'rev' ), + $this->getCompatCommentJoins( 'rev' ) + ), + ] + ]; + yield 'MCR write-both/read-old, page, user' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + ], + [ 'page', 'user' ], + [ + 'tables' => [ + 'revision', + 'page', + 'user', + 'temp_rev_user' => 'revision_actor_temp', + 'temp_rev_comment' => 'revision_comment_temp', + 'actor_rev_user' => 'actor', + 'comment_rev_comment' => 'comment', + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getContentHandlerQueryFields( 'rev' ), + $this->getUserQueryFields(), + $this->getPageQueryFields(), + $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), + $this->getCompatCommentQueryFields( 'rev' ) + ), + 'joins' => array_merge( + [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + 'user' => [ + 'LEFT JOIN', + [ + 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', + 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' + ] + ], + ], + $this->getCompatActorJoins( 'rev' ), + $this->getCompatCommentJoins( 'rev' ) + ), + ] + ]; + yield 'pre-MCR' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [], + [ + 'tables' => [ 'revision' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getContentHandlerQueryFields( 'rev' ), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [], + ] + ]; + yield 'pre-MCR, page, user' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ 'page', 'user' ], + [ + 'tables' => [ 'revision', 'page', 'user' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getContentHandlerQueryFields( 'rev' ), + $this->getPageQueryFields(), + $this->getUserQueryFields(), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], + ], + ] + ]; + yield 'pre-MCR, no model' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [], + [ + 'tables' => [ 'revision' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [], + ], + ]; + yield 'pre-MCR, no model, page' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ 'page' ], + [ + 'tables' => [ 'revision', 'page' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getPageQueryFields(), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ], ], + ], + ], + ]; + yield 'pre-MCR, no model, user' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ 'user' ], + [ + 'tables' => [ 'revision', 'user' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getUserQueryFields(), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], + ], + ], + ]; + yield 'pre-MCR, no model, text' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ 'text' ], + [ + 'tables' => [ 'revision', 'text' ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getTextQueryFields(), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ], + ], + ], + ]; + yield 'pre-MCR, no model, text, page, user' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + [ 'text', 'page', 'user' ], + [ + 'tables' => [ + 'revision', 'page', 'user', 'text' + ], + 'fields' => array_merge( + $this->getRevisionQueryFields( true ), + $this->getPageQueryFields(), + $this->getUserQueryFields(), + $this->getTextQueryFields(), + $this->getOldActorQueryFields( 'rev' ), + $this->getOldCommentQueryFields( 'rev' ) + ), + 'joins' => [ + 'page' => [ + 'INNER JOIN', + [ 'page_id = rev_page' ], + ], + 'user' => [ + 'LEFT JOIN', + [ + 'rev_user != 0', + 'user_id = rev_user', + ], + ], + 'text' => [ + 'INNER JOIN', + [ 'rev_text_id=old_id' ], + ], + ], + ], + ]; + } + + public function provideSlotsQueryInfo() { + yield 'MCR, no options' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, + ], + [], + [ + 'tables' => [ + 'slots' + ], + 'fields' => [ + 'slot_revision_id', + 'slot_content_id', + 'slot_origin', + 'slot_role_id', + ], + 'joins' => [], + ] + ]; + yield 'MCR, role option' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, + ], + [ 'role' ], + [ + 'tables' => [ + 'slots', + 'slot_roles', + ], + 'fields' => [ + 'slot_revision_id', + 'slot_content_id', + 'slot_origin', + 'slot_role_id', + 'role_name', + ], + 'joins' => [ + 'slot_roles' => [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ], + ], + ] + ]; + yield 'MCR read-new, content option' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, + ], + [ 'content' ], + [ + 'tables' => [ + 'slots', + 'content', + ], + 'fields' => [ + 'slot_revision_id', + 'slot_content_id', + 'slot_origin', + 'slot_role_id', + 'content_size', + 'content_sha1', + 'content_address', + 'content_model', + ], + 'joins' => [ + 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ], + ], + ] + ]; + yield 'MCR read-new, content and model options' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, + ], + [ 'content', 'model' ], + [ + 'tables' => [ + 'slots', + 'content', + 'content_models', + ], + 'fields' => [ + 'slot_revision_id', + 'slot_content_id', + 'slot_origin', + 'slot_role_id', + 'content_size', + 'content_sha1', + 'content_address', + 'content_model', + 'model_name', + ], + 'joins' => [ + 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ], + 'content_models' => [ 'LEFT JOIN', [ 'content_model = model_id' ] ], + ], + ] + ]; + + $db = wfGetDB( DB_REPLICA ); + + yield 'MCR write-both/read-old' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + ], + [], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + ] + ), + 'joins' => [], + ] + ]; + yield 'MCR write-both/read-old, content' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + ], + [ 'content' ], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + 'content_size' => 'slots.rev_len', + 'content_sha1' => 'slots.rev_sha1', + 'content_address' => $db->buildConcat( [ + $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), + 'model_name' => 'slots.rev_content_model', + ] + ), + 'joins' => [], + ] + ]; + yield 'MCR write-both/read-old, content, model, role' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, + ], + [ 'content', 'model', 'role' ], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + 'content_size' => 'slots.rev_len', + 'content_sha1' => 'slots.rev_sha1', + 'content_address' => $db->buildConcat( [ + $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), + 'model_name' => 'slots.rev_content_model', + ] + ), + 'joins' => [], + ] + ]; + yield 'pre-MCR' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_OLD, + ], + [], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + ] + ), + 'joins' => [], + ] + ]; + yield 'pre-MCR, content' => [ + [ + 'wgMultiContentRevisionSchemaMigrationStage' + => SCHEMA_COMPAT_OLD, + ], + [ 'content' ], + [ + 'tables' => [ + 'slots' => 'revision', + ], + 'fields' => array_merge( + [ + 'slot_revision_id' => 'slots.rev_id', + 'slot_content_id' => 'NULL', + 'slot_origin' => 'slots.rev_id', + 'role_name' => $db->addQuotes( SlotRecord::MAIN ), + 'content_size' => 'slots.rev_len', + 'content_sha1' => 'slots.rev_sha1', + 'content_address' => + $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), + 'model_name' => 'slots.rev_content_model', + ] + ), + 'joins' => [], + ] + ]; + } + + public function provideSelectFields() { + yield 'with model, comment, and actor' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + ], + 'fields' => array_merge( + [ + 'rev_id', + 'rev_page', + 'rev_text_id', + 'rev_timestamp', + 'rev_user_text', + 'rev_user', + 'rev_actor' => 'NULL', + 'rev_minor_edit', + 'rev_deleted', + 'rev_len', + 'rev_parent_id', + 'rev_sha1', + ], + $this->getContentHandlerQueryFields( 'rev' ), + [ + 'rev_comment_old' => 'rev_comment', + 'rev_comment_pk' => 'rev_id', + ] + ), + ]; + yield 'no mode, no comment, no actor' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + 'fields' => array_merge( + [ + 'rev_id', + 'rev_page', + 'rev_text_id', + 'rev_timestamp', + 'rev_user_text', + 'rev_user', + 'rev_actor' => 'NULL', + 'rev_minor_edit', + 'rev_deleted', + 'rev_len', + 'rev_parent_id', + 'rev_sha1', + ], + $this->getOldCommentQueryFields( 'rev' ) + ), + ]; + } + + public function provideSelectArchiveFields() { + yield 'with model, comment, and actor' => [ + [ + 'wgContentHandlerUseDB' => true, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, + ], + 'fields' => array_merge( + [ + 'ar_id', + 'ar_page_id', + 'ar_rev_id', + 'ar_text_id', + 'ar_timestamp', + 'ar_user_text', + 'ar_user', + 'ar_actor' => 'NULL', + 'ar_minor_edit', + 'ar_deleted', + 'ar_len', + 'ar_parent_id', + 'ar_sha1', + ], + $this->getContentHandlerQueryFields( 'ar' ), + [ + 'ar_comment_old' => 'ar_comment', + 'ar_comment_id' => 'ar_comment_id', + ] + ), + ]; + yield 'no mode, no comment, no actor' => [ + [ + 'wgContentHandlerUseDB' => false, + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ], + 'fields' => array_merge( + [ + 'ar_id', + 'ar_page_id', + 'ar_rev_id', + 'ar_text_id', + 'ar_timestamp', + 'ar_user_text', + 'ar_user', + 'ar_actor' => 'NULL', + 'ar_minor_edit', + 'ar_deleted', + 'ar_len', + 'ar_parent_id', + 'ar_sha1', + ], + $this->getOldCommentQueryFields( 'ar' ) + ), + ]; + } + + /** + * @dataProvider provideSelectFields + * @covers Revision::selectFields + */ + public function testRevisionSelectFields( $migrationStageSettings, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $this->hideDeprecated( 'Revision::selectFields' ); + $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectFields() ); + } + + /** + * @dataProvider provideSelectArchiveFields + * @covers Revision::selectArchiveFields + */ + public function testRevisionSelectArchiveFields( $migrationStageSettings, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $this->hideDeprecated( 'Revision::selectArchiveFields' ); + $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectArchiveFields() ); + } + + /** + * @covers Revision::userJoinCond + */ + public function testRevisionUserJoinCond() { + $this->hideDeprecated( 'Revision::userJoinCond' ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); + $this->overrideMwServices(); + $this->assertEquals( + [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], + Revision::userJoinCond() + ); + } + + /** + * @covers Revision::pageJoinCond + */ + public function testRevisionPageJoinCond() { + $this->hideDeprecated( 'Revision::pageJoinCond' ); + $this->assertEquals( + [ 'INNER JOIN', [ 'page_id = rev_page' ] ], + Revision::pageJoinCond() + ); + } + + /** + * @covers Revision::selectTextFields + */ + public function testRevisionSelectTextFields() { + $this->hideDeprecated( 'Revision::selectTextFields' ); + $this->assertEquals( + $this->getTextQueryFields(), + Revision::selectTextFields() + ); + } + + /** + * @covers Revision::selectPageFields + */ + public function testRevisionSelectPageFields() { + $this->hideDeprecated( 'Revision::selectPageFields' ); + $this->assertEquals( + $this->getPageQueryFields(), + Revision::selectPageFields() + ); + } + + /** + * @covers Revision::selectUserFields + */ + public function testRevisionSelectUserFields() { + $this->hideDeprecated( 'Revision::selectUserFields' ); + $this->assertEquals( + $this->getUserQueryFields(), + Revision::selectUserFields() + ); + } + + /** + * @covers Revision::getArchiveQueryInfo + * @dataProvider provideArchiveQueryInfo + */ + public function testRevisionGetArchiveQueryInfo( $migrationStageSettings, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $queryInfo = Revision::getArchiveQueryInfo(); + $this->assertQueryInfoEquals( $expected, $queryInfo ); + } + + /** + * @covers Revision::getQueryInfo + * @dataProvider provideQueryInfo + */ + public function testRevisionGetQueryInfo( $migrationStageSettings, $options, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $queryInfo = Revision::getQueryInfo( $options ); + $this->assertQueryInfoEquals( $expected, $queryInfo ); + } + + /** + * @dataProvider provideQueryInfo + * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo + */ + public function testRevisionStoreGetQueryInfo( $migrationStageSettings, $options, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $queryInfo = $store->getQueryInfo( $options ); + $this->assertQueryInfoEquals( $expected, $queryInfo ); + } + + /** + * @dataProvider provideSlotsQueryInfo + * @covers \MediaWiki\Revision\RevisionStore::getSlotsQueryInfo + */ + public function testRevisionStoreGetSlotsQueryInfo( + $migrationStageSettings, + $options, + $expected + ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $queryInfo = $store->getSlotsQueryInfo( $options ); + $this->assertQueryInfoEquals( $expected, $queryInfo ); + } + + /** + * @dataProvider provideArchiveQueryInfo + * @covers \MediaWiki\Revision\RevisionStore::getArchiveQueryInfo + */ + public function testRevisionStoreGetArchiveQueryInfo( $migrationStageSettings, $expected ) { + $this->setMwGlobals( $migrationStageSettings ); + $this->overrideMwServices(); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $queryInfo = $store->getArchiveQueryInfo(); + $this->assertQueryInfoEquals( $expected, $queryInfo ); + } + + private function assertQueryInfoEquals( $expected, $queryInfo ) { + $this->assertArrayEqualsIgnoringIntKeyOrder( + $expected['tables'], + $queryInfo['tables'], + 'tables' + ); + $this->assertArrayEqualsIgnoringIntKeyOrder( + $expected['fields'], + $queryInfo['fields'], + 'fields' + ); + $this->assertArrayEqualsIgnoringIntKeyOrder( + $expected['joins'], + $queryInfo['joins'], + 'joins' + ); + } + + /** + * Assert that the two arrays passed are equal, ignoring the order of the values that integer + * keys. + * + * Note: Failures of this assertion can be slightly confusing as the arrays are actually + * split into a string key array and an int key array before assertions occur. + * + * @param array $expected + * @param array $actual + */ + private function assertArrayEqualsIgnoringIntKeyOrder( + array $expected, + array $actual, + $message = null + ) { + $this->objectAssociativeSort( $expected ); + $this->objectAssociativeSort( $actual ); + + // Separate the int key values from the string key values so that assertion failures are + // easier to understand. + $expectedIntKeyValues = []; + $actualIntKeyValues = []; + + // Remove all int keys and re add them at the end after sorting by value + // This will result in all int keys being in the same order with same ints at the end of + // the array + foreach ( $expected as $key => $value ) { + if ( is_int( $key ) ) { + unset( $expected[$key] ); + $expectedIntKeyValues[] = $value; + } + } + foreach ( $actual as $key => $value ) { + if ( is_int( $key ) ) { + unset( $actual[$key] ); + $actualIntKeyValues[] = $value; + } + } + + $this->objectAssociativeSort( $expected ); + $this->objectAssociativeSort( $actual ); + + $this->objectAssociativeSort( $expectedIntKeyValues ); + $this->objectAssociativeSort( $actualIntKeyValues ); + + $this->assertEquals( $expected, $actual, $message ); + $this->assertEquals( $expectedIntKeyValues, $actualIntKeyValues, $message ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionRecordTests.php b/tests/phpunit/includes/Revision/RevisionRecordTests.php new file mode 100644 index 0000000000..a53ceccd83 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionRecordTests.php @@ -0,0 +1,528 @@ + [ + RevisionRecord::SUPPRESSED_ALL, + [ 'oversight' ], + true, + false + ]; + + yield 'field accessible for oversighter' => [ + RevisionRecord::DELETED_RESTRICTED | $field, + [ 'oversight' ], + true, + false + ]; + + yield 'field not accessible for sysops (ALL)' => [ + RevisionRecord::SUPPRESSED_ALL, + [ 'sysop' ], + false, + false + ]; + + yield 'field not accessible for sysops' => [ + RevisionRecord::DELETED_RESTRICTED | $field, + [ 'sysop' ], + false, + false + ]; + + yield 'field accessible for sysops' => [ + $field, + [ 'sysop' ], + true, + false + ]; + + yield 'field suppressed for logged in users' => [ + $field, + [ 'user' ], + false, + false + ]; + + yield 'unrelated field suppressed' => [ + $field === RevisionRecord::DELETED_COMMENT + ? RevisionRecord::DELETED_USER + : RevisionRecord::DELETED_COMMENT, + [ 'user' ], + true, + true + ]; + + yield 'nothing suppressed' => [ + 0, + [ 'user' ], + true, + true + ]; + } + + public function testSerialization_fails() { + $this->setExpectedException( LogicException::class ); + $rev = $this->newRevision(); + serialize( $rev ); + } + + public function provideGetComment_audience() { + return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT ); + } + + private function forceStandardPermissions() { + $this->setMwGlobals( + 'wgGroupPermissions', + [ + 'user' => [ + 'viewsuppressed' => false, + 'suppressrevision' => false, + 'deletedtext' => false, + 'deletedhistory' => false, + ], + 'sysop' => [ + 'viewsuppressed' => false, + 'suppressrevision' => false, + 'deletedtext' => true, + 'deletedhistory' => true, + ], + 'oversight' => [ + 'deletedtext' => true, + 'deletedhistory' => true, + 'viewsuppressed' => true, + 'suppressrevision' => true, + ], + ] + ); + } + + /** + * @dataProvider provideGetComment_audience + */ + public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) { + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $groups )->getUser(); + $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); + + $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' ); + + $this->assertSame( + $publicCan, + $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null, + 'public can' + ); + $this->assertSame( + $userCan, + $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null, + 'user can' + ); + } + + public function provideGetUser_audience() { + return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER ); + } + + /** + * @dataProvider provideGetUser_audience + */ + public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) { + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $groups )->getUser(); + $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); + + $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' ); + + $this->assertSame( + $publicCan, + $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null, + 'public can' + ); + $this->assertSame( + $userCan, + $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null, + 'user can' + ); + } + + public function provideGetSlot_audience() { + return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT ); + } + + /** + * @dataProvider provideGetSlot_audience + */ + public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) { + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $groups )->getUser(); + $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); + + // NOTE: slot meta-data is never suppressed, just the content is! + $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ), 'hasSlot is never suppressed' ); + $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw meta' ); + $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ), + 'public meta' ); + + $this->assertNotNull( + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ), + 'user can' + ); + + try { + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent(); + $exception = null; + } catch ( SuppressedDataException $ex ) { + $exception = $ex; + } + + $this->assertSame( + $publicCan, + $exception === null, + 'public can' + ); + + try { + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent(); + $exception = null; + } catch ( SuppressedDataException $ex ) { + $exception = $ex; + } + + $this->assertSame( + $userCan, + $exception === null, + 'user can' + ); + } + + /** + * @dataProvider provideGetSlot_audience + */ + public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) { + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $groups )->getUser(); + $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); + + $this->assertNotNull( $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' ); + + $this->assertSame( + $publicCan, + $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) !== null, + 'public can' + ); + $this->assertSame( + $userCan, + $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ) !== null, + 'user can' + ); + } + + public function testGetSlot() { + $rev = $this->newRevision(); + + $slot = $rev->getSlot( SlotRecord::MAIN ); + $this->assertNotNull( $slot, 'getSlot()' ); + $this->assertSame( 'main', $slot->getRole(), 'getRole()' ); + } + + public function testHasSlot() { + $rev = $this->newRevision(); + + $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ) ); + $this->assertFalse( $rev->hasSlot( 'xyz' ) ); + } + + public function testGetContent() { + $rev = $this->newRevision(); + + $content = $rev->getSlot( SlotRecord::MAIN ); + $this->assertNotNull( $content, 'getContent()' ); + $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' ); + } + + public function provideUserCanBitfield() { + yield [ 0, 0, [], null, true ]; + // Bitfields match, user has no permissions + yield [ + RevisionRecord::DELETED_TEXT, + RevisionRecord::DELETED_TEXT, + [], + null, + false + ]; + yield [ + RevisionRecord::DELETED_COMMENT, + RevisionRecord::DELETED_COMMENT, + [], + null, + false, + ]; + yield [ + RevisionRecord::DELETED_USER, + RevisionRecord::DELETED_USER, + [], + null, + false + ]; + yield [ + RevisionRecord::DELETED_RESTRICTED, + RevisionRecord::DELETED_RESTRICTED, + [], + null, + false, + ]; + // Bitfields match, user (admin) does have permissions + yield [ + RevisionRecord::DELETED_TEXT, + RevisionRecord::DELETED_TEXT, + [ 'sysop' ], + null, + true, + ]; + yield [ + RevisionRecord::DELETED_COMMENT, + RevisionRecord::DELETED_COMMENT, + [ 'sysop' ], + null, + true, + ]; + yield [ + RevisionRecord::DELETED_USER, + RevisionRecord::DELETED_USER, + [ 'sysop' ], + null, + true, + ]; + // Bitfields match, user (admin) does not have permissions + yield [ + RevisionRecord::DELETED_RESTRICTED, + RevisionRecord::DELETED_RESTRICTED, + [ 'sysop' ], + null, + false, + ]; + // Bitfields match, user (oversight) does have permissions + yield [ + RevisionRecord::DELETED_RESTRICTED, + RevisionRecord::DELETED_RESTRICTED, + [ 'oversight' ], + null, + true, + ]; + // Check permissions using the title + yield [ + RevisionRecord::DELETED_TEXT, + RevisionRecord::DELETED_TEXT, + [ 'sysop' ], + __METHOD__, + true, + ]; + yield [ + RevisionRecord::DELETED_TEXT, + RevisionRecord::DELETED_TEXT, + [], + __METHOD__, + false, + ]; + } + + /** + * @dataProvider provideUserCanBitfield + * @covers \MediaWiki\Revision\RevisionRecord::userCanBitfield + */ + public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) { + if ( is_string( $title ) ) { + // NOTE: Data providers cannot instantiate Title objects! See T202641. + $title = Title::newFromText( $title ); + } + + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $userGroups )->getUser(); + + $this->assertSame( + $expected, + RevisionRecord::userCanBitfield( $bitField, $field, $user, $title ) + ); + } + + public function provideHasSameContent() { + // Create some slots with content + $mainA = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'A' ) ); + $mainB = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'B' ) ); + $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) ); + $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) ); + + $initialRecordSpec = [ [ $mainA ], 12 ]; + + return [ + 'same record object' => [ + true, + $initialRecordSpec, + $initialRecordSpec, + ], + 'same record content, different object' => [ + true, + [ [ $mainA ], 12 ], + [ [ $mainA ], 13 ], + ], + 'same record content, aux slot, different object' => [ + true, + [ [ $auxA ], 12 ], + [ [ $auxB ], 13 ], + ], + 'different content' => [ + false, + [ [ $mainA ], 12 ], + [ [ $mainB ], 13 ], + ], + 'different content and number of slots' => [ + false, + [ [ $mainA ], 12 ], + [ [ $mainA, $mainB ], 13 ], + ], + ]; + } + + /** + * @note Do not call directly from a data provider! Data providers cannot instantiate + * Title objects! See T202641. + * + * @param SlotRecord[] $slots + * @param int $revId + * @return RevisionStoreRecord + */ + private function makeHasSameContentTestRecord( array $slots, $revId ) { + $title = Title::newFromText( 'provideHasSameContent' ); + $title->resetArticleID( 19 ); + $slots = new RevisionSlots( $slots ); + + return new RevisionStoreRecord( + $title, + new UserIdentityValue( 11, __METHOD__, 0 ), + CommentStoreComment::newUnsavedComment( __METHOD__ ), + (object)[ + 'rev_id' => strval( $revId ), + 'rev_page' => strval( $title->getArticleID() ), + 'rev_timestamp' => '20200101000000', + 'rev_deleted' => 0, + 'rev_minor_edit' => 0, + 'rev_parent_id' => '5', + 'rev_len' => $slots->computeSize(), + 'rev_sha1' => $slots->computeSha1(), + 'page_latest' => '18', + ], + $slots + ); + } + + /** + * @dataProvider provideHasSameContent + * @covers \MediaWiki\Revision\RevisionRecord::hasSameContent + * @group Database + */ + public function testHasSameContent( + $expected, + $recordSpec1, + $recordSpec2 + ) { + $record1 = $this->makeHasSameContentTestRecord( ...$recordSpec1 ); + $record2 = $this->makeHasSameContentTestRecord( ...$recordSpec2 ); + + $this->assertSame( + $expected, + $record1->hasSameContent( $record2 ) + ); + } + + public function provideIsDeleted() { + yield 'no deletion' => [ + 0, + [ + RevisionRecord::DELETED_TEXT => false, + RevisionRecord::DELETED_COMMENT => false, + RevisionRecord::DELETED_USER => false, + RevisionRecord::DELETED_RESTRICTED => false, + ] + ]; + yield 'text deleted' => [ + RevisionRecord::DELETED_TEXT, + [ + RevisionRecord::DELETED_TEXT => true, + RevisionRecord::DELETED_COMMENT => false, + RevisionRecord::DELETED_USER => false, + RevisionRecord::DELETED_RESTRICTED => false, + ] + ]; + yield 'text and comment deleted' => [ + RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT, + [ + RevisionRecord::DELETED_TEXT => true, + RevisionRecord::DELETED_COMMENT => true, + RevisionRecord::DELETED_USER => false, + RevisionRecord::DELETED_RESTRICTED => false, + ] + ]; + yield 'all 4 deleted' => [ + RevisionRecord::DELETED_TEXT + + RevisionRecord::DELETED_COMMENT + + RevisionRecord::DELETED_RESTRICTED + + RevisionRecord::DELETED_USER, + [ + RevisionRecord::DELETED_TEXT => true, + RevisionRecord::DELETED_COMMENT => true, + RevisionRecord::DELETED_USER => true, + RevisionRecord::DELETED_RESTRICTED => true, + ] + ]; + } + + /** + * @dataProvider provideIsDeleted + * @covers \MediaWiki\Revision\RevisionRecord::isDeleted + */ + public function testIsDeleted( $revDeleted, $assertionMap ) { + $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] ); + foreach ( $assertionMap as $deletionLevel => $expected ) { + $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) ); + } + } + + public function testIsReadyForInsertion() { + $rev = $this->newRevision(); + $this->assertTrue( $rev->isReadyForInsertion() ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php index ca13899d41..469f2816dd 100644 --- a/tests/phpunit/includes/Revision/RevisionRendererTest.php +++ b/tests/phpunit/includes/Revision/RevisionRendererTest.php @@ -6,12 +6,12 @@ use CommentStoreComment; use Content; use Language; use LogicException; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionRenderer; -use MediaWiki\Storage\MutableRevisionRecord; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\SlotRecord; -use MediaWiki\User\UserIdentityValue; +use MediaWiki\Revision\SlotRecord; use MediaWikiTestCase; +use MediaWiki\User\UserIdentityValue; use ParserOptions; use ParserOutput; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/phpunit/includes/Revision/RevisionSlotsTest.php b/tests/phpunit/includes/Revision/RevisionSlotsTest.php new file mode 100644 index 0000000000..d8e7d92b58 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionSlotsTest.php @@ -0,0 +1,257 @@ + [ + 'foo' + ]; + yield 'array of the wrong thing' => [ + [ 1, 2, 3 ] + ]; + } + + /** + * @dataProvider provideConstructorFailue + * @param $slots + * + * @covers \MediaWiki\Revision\RevisionSlots::__construct + * @covers \MediaWiki\Revision\RevisionSlots::setSlotsInternal + */ + public function testConstructorFailue( $slots ) { + $this->setExpectedException( InvalidArgumentException::class ); + + new RevisionSlots( $slots ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getSlot + */ + public function testGetSlot() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); + + $this->assertSame( $mainSlot, $slots->getSlot( SlotRecord::MAIN ) ); + $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) ); + $this->setExpectedException( RevisionAccessException::class ); + $slots->getSlot( 'nothere' ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::hasSlot + */ + public function testHasSlot() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); + + $this->assertTrue( $slots->hasSlot( SlotRecord::MAIN ) ); + $this->assertTrue( $slots->hasSlot( 'aux' ) ); + $this->assertFalse( $slots->hasSlot( 'AUX' ) ); + $this->assertFalse( $slots->hasSlot( 'xyz' ) ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getContent + */ + public function testGetContent() { + $mainContent = new WikitextContent( 'A' ); + $auxContent = new WikitextContent( 'B' ); + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, $mainContent ); + $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); + + $this->assertSame( $mainContent, $slots->getContent( SlotRecord::MAIN ) ); + $this->assertSame( $auxContent, $slots->getContent( 'aux' ) ); + $this->setExpectedException( RevisionAccessException::class ); + $slots->getContent( 'nothere' ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getSlotRoles + */ + public function testGetSlotRoles_someSlots() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); + + $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getSlotRoles + */ + public function testGetSlotRoles_noSlots() { + $slots = $this->newRevisionSlots( [] ); + + $this->assertSame( [], $slots->getSlotRoles() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getSlots + */ + public function testGetSlots() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); + $slotsArray = [ $mainSlot, $auxSlot ]; + $slots = $this->newRevisionSlots( $slotsArray ); + + $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getInheritedSlots + */ + public function testGetInheritedSlots() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newInherited( + SlotRecord::newSaved( + 7, 7, 'foo', + SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ) + ) + ); + $slotsArray = [ $mainSlot, $auxSlot ]; + $slots = $this->newRevisionSlots( $slotsArray ); + + $this->assertEquals( [ 'aux' => $auxSlot ], $slots->getInheritedSlots() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionSlots::getOriginalSlots + */ + public function testGetOriginalSlots() { + $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newInherited( + SlotRecord::newSaved( + 7, 7, 'foo', + SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ) + ) + ); + $slotsArray = [ $mainSlot, $auxSlot ]; + $slots = $this->newRevisionSlots( $slotsArray ); + + $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() ); + } + + public function provideComputeSize() { + yield [ 1, [ 'A' ] ]; + yield [ 2, [ 'AA' ] ]; + yield [ 4, [ 'AA', 'X', 'H' ] ]; + } + + /** + * @dataProvider provideComputeSize + * @covers \MediaWiki\Revision\RevisionSlots::computeSize + */ + public function testComputeSize( $expected, $contentStrings ) { + $slotsArray = []; + foreach ( $contentStrings as $key => $contentString ) { + $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); + } + $slots = $this->newRevisionSlots( $slotsArray ); + + $this->assertSame( $expected, $slots->computeSize() ); + } + + public function provideComputeSha1() { + yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ]; + yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ]; + yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ]; + } + + /** + * @dataProvider provideComputeSha1 + * @covers \MediaWiki\Revision\RevisionSlots::computeSha1 + * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings + * are returned and different Slots objects return different strings? + */ + public function testComputeSha1( $expected, $contentStrings ) { + $slotsArray = []; + foreach ( $contentStrings as $key => $contentString ) { + $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); + } + $slots = $this->newRevisionSlots( $slotsArray ); + + $this->assertSame( $expected, $slots->computeSha1() ); + } + + public function provideHasSameContent() { + $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) ); + $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) ); + $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) ); + $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ ); + $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) ); + + $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); + $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); + $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] ); + $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] ); + $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] ); + + yield 'same instance' => [ $a, $a, true ]; + yield 'same slots' => [ $a, $a2, true ]; + yield 'same content' => [ $a, $a3, true ]; + + yield 'different roles' => [ $a, $b, false ]; + yield 'different content' => [ $a, $c, false ]; + } + + /** + * @dataProvider provideHasSameContent + * @covers \MediaWiki\Revision\RevisionSlots::hasSameContent + */ + public function testHasSameContent( RevisionSlots $a, RevisionSlots $b, $same ) { + $this->assertSame( $same, $a->hasSameContent( $b ) ); + $this->assertSame( $same, $b->hasSameContent( $a ) ); + } + + public function provideGetRolesWithDifferentContent() { + $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) ); + $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) ); + $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) ); + $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ ); + $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) ); + + $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); + $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); + $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] ); + $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] ); + $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] ); + + yield 'same instance' => [ $a, $a, [] ]; + yield 'same slots' => [ $a, $a2, [] ]; + yield 'same content' => [ $a, $a3, [] ]; + + yield 'different roles' => [ $a, $b, [ 'x', 'y' ] ]; + yield 'different content' => [ $a, $c, [ 'z' ] ]; + } + + /** + * @dataProvider provideGetRolesWithDifferentContent + * @covers \MediaWiki\Revision\RevisionSlots::getRolesWithDifferentContent + */ + public function testGetRolesWithDifferentContent( RevisionSlots $a, RevisionSlots $b, $roles ) { + $this->assertArrayEquals( $roles, $a->getRolesWithDifferentContent( $b ) ); + $this->assertArrayEquals( $roles, $b->getRolesWithDifferentContent( $a ) ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php b/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php new file mode 100644 index 0000000000..355d2cef34 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php @@ -0,0 +1,1662 @@ +tablesUsed[] = 'archive'; + $this->tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'comment'; + + $this->tablesUsed += $this->getMcrTablesToReset(); + + $this->setMwGlobals( [ + 'wgMultiContentRevisionSchemaMigrationStage' => $this->getMcrMigrationStage(), + 'wgContentHandlerUseDB' => $this->getContentHandlerUseDB(), + 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, + 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, + ] ); + + $this->overrideMwServices(); + } + + protected function addCoreDBData() { + // Blank out. This would fail with a modified schema, and we don't need it. + } + + /** + * @return Title + */ + protected function getTestPageTitle() { + if ( $this->testPageTitle ) { + return $this->testPageTitle; + } + + $this->testPageTitle = Title::newFromText( 'UTPage-' . __CLASS__ ); + return $this->testPageTitle; + } + /** + * @return WikiPage + */ + protected function getTestPage() { + if ( $this->testPage ) { + return $this->testPage; + } + + $title = $this->getTestPageTitle(); + $this->testPage = WikiPage::factory( $title ); + + if ( !$this->testPage->exists() ) { + // Make sure we don't write to the live db. + $this->ensureMockDatabaseConnection( wfGetDB( DB_MASTER ) ); + + $user = static::getTestSysop()->getUser(); + + $this->testPage->doEditContent( + new WikitextContent( 'UTContent-' . __CLASS__ ), + 'UTPageSummary-' . __CLASS__, + EDIT_NEW | EDIT_SUPPRESS_RC, + false, + $user + ); + } + + return $this->testPage; + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock( array $server ) { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->setMethods( [ 'reallyOpenConnection' ] ) + ->setConstructorArgs( [ [ 'servers' => [ $server ] ] ] ) + ->getMock(); + + $lb->method( 'reallyOpenConnection' )->willReturnCallback( + function ( array $server, $dbNameOverride ) { + return $this->getDatabaseMock( $server ); + } + ); + + return $lb; + } + + /** + * @return Database|PHPUnit_Framework_MockObject_MockObject + */ + private function getDatabaseMock( array $params ) { + $db = $this->getMockBuilder( DatabaseSqlite::class ) + ->setMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] ) + ->setConstructorArgs( [ $params ] ) + ->getMock(); + + $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) ); + $db->method( 'isOpen' )->willReturn( true ); + + return $db; + } + + public function provideDomainCheck() { + yield [ false, 'test', '' ]; + yield [ 'test', 'test', '' ]; + + yield [ false, 'test', 'foo_' ]; + yield [ 'test-foo_', 'test', 'foo_' ]; + + yield [ false, 'dash-test', '' ]; + yield [ 'dash-test', 'dash-test', '' ]; + + yield [ false, 'underscore_test', 'foo_' ]; + yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ]; + } + + /** + * @dataProvider provideDomainCheck + * @covers \MediaWiki\Revision\RevisionStore::checkDatabaseWikiId + */ + public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) { + $this->setMwGlobals( + [ + 'wgDBname' => $dbName, + 'wgDBprefix' => $dbPrefix, + ] + ); + + $loadBalancer = $this->getLoadBalancerMock( + [ + 'host' => '*dummy*', + 'dbDirectory' => '*dummy*', + 'user' => 'test', + 'password' => 'test', + 'flags' => 0, + 'variables' => [], + 'schema' => '', + 'cliMode' => true, + 'agent' => '', + 'load' => 100, + 'profiler' => null, + 'trxProfiler' => new TransactionProfiler(), + 'connLogger' => new \Psr\Log\NullLogger(), + 'queryLogger' => new \Psr\Log\NullLogger(), + 'errorLogger' => function () { + }, + 'deprecationLogger' => function () { + }, + 'type' => 'test', + 'dbname' => $dbName, + 'tablePrefix' => $dbPrefix, + ] + ); + $db = $loadBalancer->getConnection( DB_REPLICA ); + + /** @var SqlBlobStore $blobStore */ + $blobStore = $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor() + ->getMock(); + + $store = new RevisionStore( + $loadBalancer, + $blobStore, + new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ), + MediaWikiServices::getInstance()->getCommentStore(), + MediaWikiServices::getInstance()->getContentModelStore(), + MediaWikiServices::getInstance()->getSlotRoleStore(), + $this->getMcrMigrationStage(), + MediaWikiServices::getInstance()->getActorMigration(), + $wikiId + ); + + $count = $store->countRevisionsByPageId( $db, 0 ); + + // Dummy check to make PhpUnit happy. We are really only interested in + // countRevisionsByPageId not failing due to the DB domain check. + $this->assertSame( 0, $count ); + } + + protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) { + $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() ); + $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() ); + $this->assertEquals( $l1->getFragment(), $l2->getFragment() ); + $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() ); + } + + protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) { + $this->assertEquals( + $r1->getPageAsLinkTarget()->getNamespace(), + $r2->getPageAsLinkTarget()->getNamespace() + ); + + $this->assertEquals( + $r1->getPageAsLinkTarget()->getText(), + $r2->getPageAsLinkTarget()->getText() + ); + + if ( $r1->getParentId() ) { + $this->assertEquals( $r1->getParentId(), $r2->getParentId() ); + } + + $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() ); + $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() ); + $this->assertEquals( $r1->getComment(), $r2->getComment() ); + $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() ); + $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() ); + $this->assertEquals( $r1->getSha1(), $r2->getSha1() ); + $this->assertEquals( $r1->getSize(), $r2->getSize() ); + $this->assertEquals( $r1->getPageId(), $r2->getPageId() ); + $this->assertArrayEquals( $r1->getSlotRoles(), $r2->getSlotRoles() ); + $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() ); + $this->assertEquals( $r1->isMinor(), $r2->isMinor() ); + foreach ( $r1->getSlotRoles() as $role ) { + $this->assertSlotRecordsEqual( $r1->getSlot( $role ), $r2->getSlot( $role ) ); + $this->assertTrue( $r1->getContent( $role )->equals( $r2->getContent( $role ) ) ); + } + foreach ( [ + RevisionRecord::DELETED_TEXT, + RevisionRecord::DELETED_COMMENT, + RevisionRecord::DELETED_USER, + RevisionRecord::DELETED_RESTRICTED, + ] as $field ) { + $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) ); + } + } + + protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) { + $this->assertSame( $s1->getRole(), $s2->getRole() ); + $this->assertSame( $s1->getModel(), $s2->getModel() ); + $this->assertSame( $s1->getFormat(), $s2->getFormat() ); + $this->assertSame( $s1->getSha1(), $s2->getSha1() ); + $this->assertSame( $s1->getSize(), $s2->getSize() ); + $this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) ); + + $s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null; + $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null; + } + + protected function assertRevisionCompleteness( RevisionRecord $r ) { + $this->assertTrue( $r->hasSlot( SlotRecord::MAIN ) ); + $this->assertInstanceOf( SlotRecord::class, $r->getSlot( SlotRecord::MAIN ) ); + $this->assertInstanceOf( Content::class, $r->getContent( SlotRecord::MAIN ) ); + + foreach ( $r->getSlotRoles() as $role ) { + $this->assertSlotCompleteness( $r, $r->getSlot( $role ) ); + } + } + + protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) { + $this->assertTrue( $slot->hasAddress() ); + $this->assertSame( $r->getId(), $slot->getRevision() ); + + $this->assertInstanceOf( Content::class, $slot->getContent() ); + } + + /** + * @param mixed[] $details + * + * @return RevisionRecord + */ + private function getRevisionRecordFromDetailsArray( $details = [] ) { + // Convert some values that can't be provided by dataProviders + if ( isset( $details['user'] ) && $details['user'] === true ) { + $details['user'] = $this->getTestUser()->getUser(); + } + if ( isset( $details['page'] ) && $details['page'] === true ) { + $details['page'] = $this->getTestPage()->getId(); + } + if ( isset( $details['parent'] ) && $details['parent'] === true ) { + $details['parent'] = $this->getTestPage()->getLatest(); + } + + // Create the RevisionRecord with any available data + $rev = new MutableRevisionRecord( $this->getTestPageTitle() ); + isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null; + isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null; + isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null; + isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null; + isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null; + isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null; + isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null; + isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null; + isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null; + isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null; + isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null; + + if ( isset( $details['content'] ) ) { + foreach ( $details['content'] as $role => $content ) { + $rev->setContent( $role, $content ); + } + } + + return $rev; + } + + public function provideInsertRevisionOn_successes() { + yield 'Bare minimum revision insertion' => [ + [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'page' => true, + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + ]; + yield 'Detailed revision insertion' => [ + [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'parent' => true, + 'page' => true, + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + 'minor' => true, + 'visibility' => RevisionRecord::DELETED_RESTRICTED, + ], + ]; + } + + protected function getRandomCommentStoreComment() { + return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) ); + } + + /** + * @dataProvider provideInsertRevisionOn_successes + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + * @covers \MediaWiki\Revision\RevisionStore::insertSlotRowOn + * @covers \MediaWiki\Revision\RevisionStore::insertContentRowOn + */ + public function testInsertRevisionOn_successes( + array $revDetails = [] + ) { + $title = $this->getTestPageTitle(); + $rev = $this->getRevisionRecordFromDetailsArray( $revDetails ); + + $this->overrideMwServices(); + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) ); + + // is the new revision correct? + $this->assertRevisionCompleteness( $return ); + $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() ); + $this->assertRevisionRecordsEqual( $rev, $return ); + + // can we load it from the store? + $loaded = $store->getRevisionById( $return->getId() ); + $this->assertRevisionCompleteness( $loaded ); + $this->assertRevisionRecordsEqual( $return, $loaded ); + + // can we find it directly in the database? + $this->assertRevisionExistsInDatabase( $return ); + } + + protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { + $row = $this->revisionToRow( new Revision( $rev ), [] ); + + // unset nulled fields + unset( $row->rev_content_model ); + unset( $row->rev_content_format ); + + // unset fake fields + unset( $row->rev_comment_text ); + unset( $row->rev_comment_data ); + unset( $row->rev_comment_cid ); + unset( $row->rev_comment_id ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $store->getQueryInfo( [ 'user' ] ); + + $row = get_object_vars( $row ); + $this->assertSelect( + $queryInfo['tables'], + array_keys( $row ), + [ 'rev_id' => $rev->getId() ], + [ array_values( $row ) ], + [], + $queryInfo['joins'] + ); + } + + /** + * @param SlotRecord $a + * @param SlotRecord $b + */ + protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { + // Assert that the same blob address has been used. + $this->assertSame( $a->getAddress(), $b->getAddress() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + */ + public function testInsertRevisionOn_blobAddressExists() { + $title = $this->getTestPageTitle(); + $revDetails = [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'parent' => true, + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ]; + + $this->overrideMwServices(); + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + // Insert the first revision + $revOne = $this->getRevisionRecordFromDetailsArray( $revDetails ); + $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) ); + $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() ); + $this->assertRevisionRecordsEqual( $revOne, $firstReturn ); + + // Insert a second revision inheriting the same blob address + $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( SlotRecord::MAIN ) ); + $revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails ); + $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) ); + $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() ); + $this->assertRevisionRecordsEqual( $revTwo, $secondReturn ); + + $firstMainSlot = $firstReturn->getSlot( SlotRecord::MAIN ); + $secondMainSlot = $secondReturn->getSlot( SlotRecord::MAIN ); + + $this->assertSameSlotContent( $firstMainSlot, $secondMainSlot ); + + // And that different revisions have been created. + $this->assertNotSame( $firstReturn->getId(), $secondReturn->getId() ); + + // Make sure the slot rows reference the correct revision + $this->assertSame( $firstReturn->getId(), $firstMainSlot->getRevision() ); + $this->assertSame( $secondReturn->getId(), $secondMainSlot->getRevision() ); + } + + public function provideInsertRevisionOn_failures() { + yield 'no slot' => [ + [ + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + new InvalidArgumentException( 'main slot must be provided' ) + ]; + yield 'no main slot' => [ + [ + 'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ), + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + 'user' => true, + ], + new InvalidArgumentException( 'main slot must be provided' ) + ]; + yield 'no timestamp' => [ + [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'comment' => $this->getRandomCommentStoreComment(), + 'user' => true, + ], + new IncompleteRevisionException( 'timestamp field must not be NULL!' ) + ]; + yield 'no comment' => [ + [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'timestamp' => '20171117010101', + 'user' => true, + ], + new IncompleteRevisionException( 'comment must not be NULL!' ) + ]; + yield 'no user' => [ + [ + 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), + 'comment' => $this->getRandomCommentStoreComment(), + 'timestamp' => '20171117010101', + ], + new IncompleteRevisionException( 'user must not be NULL!' ) + ]; + } + + /** + * @dataProvider provideInsertRevisionOn_failures + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + */ + public function testInsertRevisionOn_failures( + array $revDetails = [], + Exception $exception + ) { + $rev = $this->getRevisionRecordFromDetailsArray( $revDetails ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $this->setExpectedException( + get_class( $exception ), + $exception->getMessage(), + $exception->getCode() + ); + $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) ); + } + + public function provideNewNullRevision() { + yield [ + Title::newFromText( 'UTPage_notAutoCreated' ), + [ 'content' => [ 'main' => new WikitextContent( 'Flubber1' ) ] ], + CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ), + true, + ]; + yield [ + Title::newFromText( 'UTPage_notAutoCreated' ), + [ 'content' => [ 'main' => new WikitextContent( 'Flubber2' ) ] ], + CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ), + false, + ]; + } + + /** + * @dataProvider provideNewNullRevision + * @covers \MediaWiki\Revision\RevisionStore::newNullRevision + * @covers \MediaWiki\Revision\RevisionStore::findSlotContentId + */ + public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) { + $this->overrideMwServices(); + + $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser(); + $page = WikiPage::factory( $title ); + + if ( !$page->exists() ) { + $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__, EDIT_NEW ); + } + + $revDetails['page'] = $page->getId(); + $revDetails['timestamp'] = wfTimestampNow(); + $revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' ); + $revDetails['user'] = $user; + + $baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails ); + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $dbw = wfGetDB( DB_MASTER ); + $baseRev = $store->insertRevisionOn( $baseRev, $dbw ); + $page->updateRevisionOn( $dbw, new Revision( $baseRev ), $page->getLatest() ); + + $record = $store->newNullRevision( + wfGetDB( DB_MASTER ), + $title, + $comment, + $minor, + $user + ); + + $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() ); + $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() ); + $this->assertEquals( $comment, $record->getComment() ); + $this->assertEquals( $minor, $record->isMinor() ); + $this->assertEquals( $user->getName(), $record->getUser()->getName() ); + $this->assertEquals( $baseRev->getId(), $record->getParentId() ); + + $this->assertArrayEquals( + $baseRev->getSlotRoles(), + $record->getSlotRoles() + ); + + foreach ( $baseRev->getSlotRoles() as $role ) { + $parentSlot = $baseRev->getSlot( $role ); + $slot = $record->getSlot( $role ); + + $this->assertTrue( $slot->isInherited(), 'isInherited' ); + $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' ); + $this->assertSameSlotContent( $parentSlot, $slot ); + } + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newNullRevision + */ + public function testNewNullRevision_nonExistingTitle() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $record = $store->newNullRevision( + wfGetDB( DB_MASTER ), + Title::newFromText( __METHOD__ . '.iDontExist!' ), + CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ), + false, + TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser() + ); + $this->assertNull( $record ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRcIdIfUnpatrolled + */ + public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() { + $page = $this->getTestPage(); + $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revisionRecord = $store->getRevisionById( $rev->getId() ); + $result = $store->getRcIdIfUnpatrolled( $revisionRecord ); + + $this->assertGreaterThan( 0, $result ); + $this->assertSame( + $store->getRecentChange( $revisionRecord )->getAttribute( 'rc_id' ), + $result + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRcIdIfUnpatrolled + */ + public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() { + // This assumes that sysops are auto patrolled + $sysop = $this->getTestSysop()->getUser(); + $page = $this->getTestPage(); + $status = $page->doEditContent( + new WikitextContent( __METHOD__ ), + __METHOD__, + 0, + false, + $sysop + ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revisionRecord = $store->getRevisionById( $rev->getId() ); + $result = $store->getRcIdIfUnpatrolled( $revisionRecord ); + + $this->assertSame( 0, $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRecentChange + */ + public function testGetRecentChange() { + $page = $this->getTestPage(); + $content = new WikitextContent( __METHOD__ ); + $status = $page->doEditContent( $content, __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revRecord = $store->getRevisionById( $rev->getId() ); + $recentChange = $store->getRecentChange( $revRecord ); + + $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) ); + $this->assertEquals( $rev->getRecentChange(), $recentChange ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRevisionById + */ + public function testGetRevisionById() { + $page = $this->getTestPage(); + $content = new WikitextContent( __METHOD__ ); + $status = $page->doEditContent( $content, __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revRecord = $store->getRevisionById( $rev->getId() ); + + $this->assertSame( $rev->getId(), $revRecord->getId() ); + $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); + $this->assertSame( __METHOD__, $revRecord->getComment()->text ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRevisionByTitle + */ + public function testGetRevisionByTitle() { + $page = $this->getTestPage(); + $content = new WikitextContent( __METHOD__ ); + $status = $page->doEditContent( $content, __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revRecord = $store->getRevisionByTitle( $page->getTitle() ); + + $this->assertSame( $rev->getId(), $revRecord->getId() ); + $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); + $this->assertSame( __METHOD__, $revRecord->getComment()->text ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRevisionByPageId + */ + public function testGetRevisionByPageId() { + $page = $this->getTestPage(); + $content = new WikitextContent( __METHOD__ ); + $status = $page->doEditContent( $content, __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revRecord = $store->getRevisionByPageId( $page->getId() ); + + $this->assertSame( $rev->getId(), $revRecord->getId() ); + $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); + $this->assertSame( __METHOD__, $revRecord->getComment()->text ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getRevisionByTimestamp + */ + public function testGetRevisionByTimestamp() { + // Make sure there is 1 second between the last revision and the rev we create... + // Otherwise we might not get the correct revision and the test may fail... + // :( + $page = $this->getTestPage(); + sleep( 1 ); + $content = new WikitextContent( __METHOD__ ); + $status = $page->doEditContent( $content, __METHOD__ ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $revRecord = $store->getRevisionByTimestamp( + $page->getTitle(), + $rev->getTimestamp() + ); + + $this->assertSame( $rev->getId(), $revRecord->getId() ); + $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); + $this->assertSame( __METHOD__, $revRecord->getComment()->text ); + } + + protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) { + // XXX: the WikiPage object loads another RevisionRecord from the database. Not great. + $page = WikiPage::factory( $rev->getTitle() ); + + $fields = [ + 'rev_id' => (string)$rev->getId(), + 'rev_page' => (string)$rev->getPage(), + 'rev_timestamp' => $this->db->timestamp( $rev->getTimestamp() ), + 'rev_user_text' => (string)$rev->getUserText(), + 'rev_user' => (string)$rev->getUser(), + 'rev_minor_edit' => $rev->isMinor() ? '1' : '0', + 'rev_deleted' => (string)$rev->getVisibility(), + 'rev_len' => (string)$rev->getSize(), + 'rev_parent_id' => (string)$rev->getParentId(), + 'rev_sha1' => (string)$rev->getSha1(), + ]; + + if ( in_array( 'page', $options ) ) { + $fields += [ + 'page_namespace' => (string)$page->getTitle()->getNamespace(), + 'page_title' => $page->getTitle()->getDBkey(), + 'page_id' => (string)$page->getId(), + 'page_latest' => (string)$page->getLatest(), + 'page_is_redirect' => $page->isRedirect() ? '1' : '0', + 'page_len' => (string)$page->getContent()->getSize(), + ]; + } + + if ( in_array( 'user', $options ) ) { + $fields += [ + 'user_name' => (string)$rev->getUserText(), + ]; + } + + if ( in_array( 'comment', $options ) ) { + $fields += [ + 'rev_comment_text' => $rev->getComment(), + 'rev_comment_data' => null, + 'rev_comment_cid' => null, + ]; + } + + if ( $rev->getId() ) { + $fields += [ + 'rev_id' => (string)$rev->getId(), + ]; + } + + return (object)$fields; + } + + protected function assertRevisionRecordMatchesRevision( + Revision $rev, + RevisionRecord $record + ) { + $this->assertSame( $rev->getId(), $record->getId() ); + $this->assertSame( $rev->getPage(), $record->getPageId() ); + $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() ); + $this->assertSame( $rev->getUserText(), $record->getUser()->getName() ); + $this->assertSame( $rev->getUser(), $record->getUser()->getId() ); + $this->assertSame( $rev->isMinor(), $record->isMinor() ); + $this->assertSame( $rev->getVisibility(), $record->getVisibility() ); + $this->assertSame( $rev->getSize(), $record->getSize() ); + /** + * @note As of MW 1.31, the database schema allows the parent ID to be + * NULL to indicate that it is unknown. + */ + $expectedParent = $rev->getParentId(); + if ( $expectedParent === null ) { + $expectedParent = 0; + } + $this->assertSame( $expectedParent, $record->getParentId() ); + $this->assertSame( $rev->getSha1(), $record->getSha1() ); + $this->assertSame( $rev->getComment(), $record->getComment()->text ); + $this->assertSame( $rev->getContentFormat(), + $record->getContent( SlotRecord::MAIN )->getDefaultFormat() ); + $this->assertSame( $rev->getContentModel(), $record->getContent( SlotRecord::MAIN )->getModel() ); + $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() ); + + $revRec = $rev->getRevisionRecord(); + $revMain = $revRec->getSlot( SlotRecord::MAIN ); + $recMain = $record->getSlot( SlotRecord::MAIN ); + + $this->assertSame( $revMain->hasOrigin(), $recMain->hasOrigin(), 'hasOrigin' ); + $this->assertSame( $revMain->hasAddress(), $recMain->hasAddress(), 'hasAddress' ); + $this->assertSame( $revMain->hasContentId(), $recMain->hasContentId(), 'hasContentId' ); + + if ( $revMain->hasOrigin() ) { + $this->assertSame( $revMain->getOrigin(), $recMain->getOrigin(), 'getOrigin' ); + } + + if ( $revMain->hasAddress() ) { + $this->assertSame( $revMain->getAddress(), $recMain->getAddress(), 'getAddress' ); + } + + if ( $revMain->hasContentId() ) { + $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); + } + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo + */ + public function testNewRevisionFromRow_getQueryInfo() { + $page = $this->getTestPage(); + $text = __METHOD__ . 'a-ä'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'a' + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $info = $store->getQueryInfo(); + $row = $this->db->selectRow( + $info['tables'], + $info['fields'], + [ 'rev_id' => $rev->getId() ], + __METHOD__, + [], + $info['joins'] + ); + $record = $store->newRevisionFromRow( + $row, + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_anonEdit() { + $page = $this->getTestPage(); + $text = __METHOD__ . 'a-ä'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'a' + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $record = $store->newRevisionFromRow( + $this->revisionToRow( $rev ), + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_anonEdit_legacyEncoding() { + $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); + $this->overrideMwServices(); + $page = $this->getTestPage(); + $text = __METHOD__ . 'a-ä'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'a' + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $record = $store->newRevisionFromRow( + $this->revisionToRow( $rev ), + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_userEdit() { + $page = $this->getTestPage(); + $text = __METHOD__ . 'b-ä'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'b', + 0, + false, + $this->getTestUser()->getUser() + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $record = $store->newRevisionFromRow( + $this->revisionToRow( $rev ), + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow + * @covers \MediaWiki\Revision\RevisionStore::getArchiveQueryInfo + */ + public function testNewRevisionFromArchiveRow_getArchiveQueryInfo() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $title = Title::newFromText( __METHOD__ ); + $text = __METHOD__ . '-bä'; + $page = WikiPage::factory( $title ); + /** @var Revision $orig */ + $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ ) + ->value['revision']; + $page->doDeleteArticle( __METHOD__ ); + + $db = wfGetDB( DB_MASTER ); + $arQuery = $store->getArchiveQueryInfo(); + $res = $db->select( + $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], + __METHOD__, [], $arQuery['joins'] + ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + $record = $store->newRevisionFromArchiveRow( $row ); + + $this->assertRevisionRecordMatchesRevision( $orig, $record ); + $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow + */ + public function testNewRevisionFromArchiveRow_legacyEncoding() { + $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); + $this->overrideMwServices(); + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $title = Title::newFromText( __METHOD__ ); + $text = __METHOD__ . '-bä'; + $page = WikiPage::factory( $title ); + /** @var Revision $orig */ + $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ ) + ->value['revision']; + $page->doDeleteArticle( __METHOD__ ); + + $db = wfGetDB( DB_MASTER ); + $arQuery = $store->getArchiveQueryInfo(); + $res = $db->select( + $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], + __METHOD__, [], $arQuery['joins'] + ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + $record = $store->newRevisionFromArchiveRow( $row ); + + $this->assertRevisionRecordMatchesRevision( $orig, $record ); + $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow + */ + public function testNewRevisionFromArchiveRow_no_user() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + $row = (object)[ + 'ar_id' => '1', + 'ar_page_id' => '2', + 'ar_namespace' => '0', + 'ar_title' => 'Something', + 'ar_rev_id' => '2', + 'ar_text_id' => '47', + 'ar_timestamp' => '20180528192356', + 'ar_minor_edit' => '0', + 'ar_deleted' => '0', + 'ar_len' => '78', + 'ar_parent_id' => '0', + 'ar_sha1' => 'deadbeef', + 'ar_comment_text' => 'whatever', + 'ar_comment_data' => null, + 'ar_comment_cid' => null, + 'ar_user' => '0', + 'ar_user_text' => '', // this is the important bit + 'ar_actor' => null, + 'ar_content_format' => null, + 'ar_content_model' => null, + ]; + + \Wikimedia\suppressWarnings(); + $record = $store->newRevisionFromArchiveRow( $row ); + \Wikimedia\suppressWarnings( true ); + + $this->assertInstanceOf( RevisionRecord::class, $record ); + $this->assertInstanceOf( UserIdentityValue::class, $record->getUser() ); + $this->assertSame( 'Unknown user', $record->getUser()->getName() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_no_user() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $title = Title::newFromText( __METHOD__ ); + + $row = (object)[ + 'rev_id' => '2', + 'rev_page' => '2', + 'page_namespace' => '0', + 'page_title' => $title->getText(), + 'rev_text_id' => '47', + 'rev_timestamp' => '20180528192356', + 'rev_minor_edit' => '0', + 'rev_deleted' => '0', + 'rev_len' => '78', + 'rev_parent_id' => '0', + 'rev_sha1' => 'deadbeef', + 'rev_comment_text' => 'whatever', + 'rev_comment_data' => null, + 'rev_comment_cid' => null, + 'rev_user' => '0', + 'rev_user_text' => '', // this is the important bit + 'rev_actor' => null, + 'rev_content_format' => null, + 'rev_content_model' => null, + ]; + + \Wikimedia\suppressWarnings(); + $record = $store->newRevisionFromRow( $row, 0, $title ); + \Wikimedia\suppressWarnings( true ); + + $this->assertNotNull( $record ); + $this->assertNotNull( $record->getUser() ); + $this->assertNotEmpty( $record->getUser()->getName() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn + */ + public function testInsertRevisionOn_archive() { + // This is a round trip test for deletion and undeletion of a + // revision row via the archive table. + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $title = Title::newFromText( __METHOD__ ); + + $page = WikiPage::factory( $title ); + /** @var Revision $origRev */ + $page->doEditContent( new WikitextContent( "First" ), __METHOD__ . '-first' ); + $origRev = $page->doEditContent( new WikitextContent( "Foo" ), __METHOD__ ) + ->value['revision']; + $orig = $origRev->getRevisionRecord(); + $page->doDeleteArticle( __METHOD__ ); + + // re-create page, so we can later load revisions for it + $page->doEditContent( new WikitextContent( 'Two' ), __METHOD__ ); + + $db = wfGetDB( DB_MASTER ); + $arQuery = $store->getArchiveQueryInfo(); + $row = $db->selectRow( + $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], + __METHOD__, [], $arQuery['joins'] + ); + + $this->assertNotFalse( $row, 'query failed' ); + + $record = $store->newRevisionFromArchiveRow( + $row, + 0, + $title, + [ 'page_id' => $title->getArticleID() ] + ); + + $restored = $store->insertRevisionOn( $record, $db ); + + // is the new revision correct? + $this->assertRevisionCompleteness( $restored ); + $this->assertRevisionRecordsEqual( $record, $restored ); + + // does the new revision use the original slot? + $recMain = $record->getSlot( SlotRecord::MAIN ); + $restMain = $restored->getSlot( SlotRecord::MAIN ); + $this->assertSame( $recMain->getAddress(), $restMain->getAddress() ); + $this->assertSame( $recMain->getContentId(), $restMain->getContentId() ); + $this->assertSame( $recMain->getOrigin(), $restMain->getOrigin() ); + $this->assertSame( 'Foo', $restMain->getContent()->serialize() ); + + // can we load it from the store? + $loaded = $store->getRevisionById( $restored->getId() ); + $this->assertNotNull( $loaded ); + $this->assertRevisionCompleteness( $loaded ); + $this->assertRevisionRecordsEqual( $restored, $loaded ); + + // can we find it directly in the database? + $this->assertRevisionExistsInDatabase( $restored ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromId + */ + public function testLoadRevisionFromId() { + $title = Title::newFromText( __METHOD__ ); + $page = WikiPage::factory( $title ); + /** @var Revision $rev */ + $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() ); + $this->assertRevisionRecordMatchesRevision( $rev, $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromPageId + */ + public function testLoadRevisionFromPageId() { + $title = Title::newFromText( __METHOD__ ); + $page = WikiPage::factory( $title ); + /** @var Revision $rev */ + $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() ); + $this->assertRevisionRecordMatchesRevision( $rev, $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromTitle + */ + public function testLoadRevisionFromTitle() { + $title = Title::newFromText( __METHOD__ ); + $page = WikiPage::factory( $title ); + /** @var Revision $rev */ + $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title ); + $this->assertRevisionRecordMatchesRevision( $rev, $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromTimestamp + */ + public function testLoadRevisionFromTimestamp() { + $title = Title::newFromText( __METHOD__ ); + $page = WikiPage::factory( $title ); + /** @var Revision $revOne */ + $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + // Sleep to ensure different timestamps... )(evil) + sleep( 1 ); + /** @var Revision $revTwo */ + $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $this->assertNull( + $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' ) + ); + $this->assertSame( + $revOne->getId(), + $store->loadRevisionFromTimestamp( + wfGetDB( DB_MASTER ), + $title, + $revOne->getTimestamp() + )->getId() + ); + $this->assertSame( + $revTwo->getId(), + $store->loadRevisionFromTimestamp( + wfGetDB( DB_MASTER ), + $title, + $revTwo->getTimestamp() + )->getId() + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::listRevisionSizes + */ + public function testGetParentLengths() { + $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); + /** @var Revision $revOne */ + $revOne = $page->doEditContent( + new WikitextContent( __METHOD__ ), __METHOD__ + )->value['revision']; + /** @var Revision $revTwo */ + $revTwo = $page->doEditContent( + new WikitextContent( __METHOD__ . '2' ), __METHOD__ + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $this->assertSame( + [ + $revOne->getId() => strlen( __METHOD__ ), + ], + $store->listRevisionSizes( + wfGetDB( DB_MASTER ), + [ $revOne->getId() ] + ) + ); + $this->assertSame( + [ + $revOne->getId() => strlen( __METHOD__ ), + $revTwo->getId() => strlen( __METHOD__ ) + 1, + ], + $store->listRevisionSizes( + wfGetDB( DB_MASTER ), + [ $revOne->getId(), $revTwo->getId() ] + ) + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getPreviousRevision + */ + public function testGetPreviousRevision() { + $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); + /** @var Revision $revOne */ + $revOne = $page->doEditContent( + new WikitextContent( __METHOD__ ), __METHOD__ + )->value['revision']; + /** @var Revision $revTwo */ + $revTwo = $page->doEditContent( + new WikitextContent( __METHOD__ . '2' ), __METHOD__ + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $this->assertNull( + $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) ) + ); + $this->assertSame( + $revOne->getId(), + $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId() + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getNextRevision + */ + public function testGetNextRevision() { + $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); + /** @var Revision $revOne */ + $revOne = $page->doEditContent( + new WikitextContent( __METHOD__ ), __METHOD__ + )->value['revision']; + /** @var Revision $revTwo */ + $revTwo = $page->doEditContent( + new WikitextContent( __METHOD__ . '2' ), __METHOD__ + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $this->assertSame( + $revTwo->getId(), + $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId() + ); + $this->assertNull( + $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) ) + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getTimestampFromId + */ + public function testGetTimestampFromId_found() { + $page = $this->getTestPage(); + /** @var Revision $rev */ + $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->getTimestampFromId( + $page->getTitle(), + $rev->getId() + ); + + $this->assertSame( $rev->getTimestamp(), $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getTimestampFromId + */ + public function testGetTimestampFromId_notFound() { + $page = $this->getTestPage(); + /** @var Revision $rev */ + $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) + ->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->getTimestampFromId( + $page->getTitle(), + $rev->getId() + 1 + ); + + $this->assertFalse( $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::countRevisionsByPageId + */ + public function testCountRevisionsByPageId() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); + + $this->assertSame( + 0, + $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) + ); + $page->doEditContent( new WikitextContent( 'a' ), 'a' ); + $this->assertSame( + 1, + $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) + ); + $page->doEditContent( new WikitextContent( 'b' ), 'b' ); + $this->assertSame( + 2, + $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::countRevisionsByTitle + */ + public function testCountRevisionsByTitle() { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); + + $this->assertSame( + 0, + $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) + ); + $page->doEditContent( new WikitextContent( 'a' ), 'a' ); + $this->assertSame( + 1, + $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) + ); + $page->doEditContent( new WikitextContent( 'b' ), 'b' ); + $this->assertSame( + 2, + $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) + ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::userWasLastToEdit + */ + public function testUserWasLastToEdit_false() { + $sysop = $this->getTestSysop()->getUser(); + $page = $this->getTestPage(); + $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->userWasLastToEdit( + wfGetDB( DB_MASTER ), + $page->getId(), + $sysop->getId(), + '20160101010101' + ); + $this->assertFalse( $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::userWasLastToEdit + */ + public function testUserWasLastToEdit_true() { + $startTime = wfTimestampNow(); + $sysop = $this->getTestSysop()->getUser(); + $page = $this->getTestPage(); + $page->doEditContent( + new WikitextContent( __METHOD__ ), + __METHOD__, + 0, + false, + $sysop + ); + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $result = $store->userWasLastToEdit( + wfGetDB( DB_MASTER ), + $page->getId(), + $sysop->getId(), + $startTime + ); + $this->assertTrue( $result ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getKnownCurrentRevision + */ + public function testGetKnownCurrentRevision() { + $page = $this->getTestPage(); + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( __METHOD__ . 'b' ), + __METHOD__ . 'b', + 0, + false, + $this->getTestUser()->getUser() + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $record = $store->getKnownCurrentRevision( + $page->getTitle(), + $rev->getId() + ); + + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + } + + public function provideNewMutableRevisionFromArray() { + yield 'Basic array, content object' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'content' => new WikitextContent( 'Some Content' ), + ] + ]; + yield 'Basic array, serialized text' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(), + ] + ]; + yield 'Basic array, serialized text, utf-8 flags' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(), + 'flags' => 'utf-8', + ] + ]; + yield 'Basic array, with title' => [ + [ + 'title' => Title::newFromText( 'SomeText' ), + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.2', + 'user' => 0, + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'content' => new WikitextContent( 'Some Content' ), + ] + ]; + yield 'Basic array, no user field' => [ + [ + 'id' => 2, + 'page' => 1, + 'timestamp' => '20171017114835', + 'user_text' => '111.0.1.3', + 'minor_edit' => false, + 'deleted' => 0, + 'len' => 46, + 'parent_id' => 1, + 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'comment' => 'Goat Comment!', + 'content' => new WikitextContent( 'Some Content' ), + ] + ]; + } + + /** + * @dataProvider provideNewMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray + */ + public function testNewMutableRevisionFromArray( array $array ) { + $store = MediaWikiServices::getInstance()->getRevisionStore(); + + // HACK: if $array['page'] is given, make sure that the page exists + if ( isset( $array['page'] ) ) { + $t = Title::newFromID( $array['page'] ); + if ( !$t || !$t->exists() ) { + $t = Title::makeTitle( NS_MAIN, __METHOD__ ); + $info = $this->insertPage( $t ); + $array['page'] = $info['id']; + } + } + + $result = $store->newMutableRevisionFromArray( $array ); + + if ( isset( $array['id'] ) ) { + $this->assertSame( $array['id'], $result->getId() ); + } + if ( isset( $array['page'] ) ) { + $this->assertSame( $array['page'], $result->getPageId() ); + } + $this->assertSame( $array['timestamp'], $result->getTimestamp() ); + $this->assertSame( $array['user_text'], $result->getUser()->getName() ); + if ( isset( $array['user'] ) ) { + $this->assertSame( $array['user'], $result->getUser()->getId() ); + } + $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() ); + $this->assertSame( $array['deleted'], $result->getVisibility() ); + $this->assertSame( $array['len'], $result->getSize() ); + $this->assertSame( $array['parent_id'], $result->getParentId() ); + $this->assertSame( $array['sha1'], $result->getSha1() ); + $this->assertSame( $array['comment'], $result->getComment()->text ); + if ( isset( $array['content'] ) ) { + foreach ( $array['content'] as $role => $content ) { + $this->assertTrue( + $result->getContent( $role )->equals( $content ) + ); + } + } elseif ( isset( $array['text'] ) ) { + $this->assertSame( $array['text'], + $result->getSlot( SlotRecord::MAIN )->getContent()->serialize() ); + } elseif ( isset( $array['content_format'] ) ) { + $this->assertSame( + $array['content_format'], + $result->getSlot( SlotRecord::MAIN )->getContent()->getDefaultFormat() + ); + $this->assertSame( $array['content_model'], $result->getSlot( SlotRecord::MAIN )->getModel() ); + } + } + + /** + * @dataProvider provideNewMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray + */ + public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) { + $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); + + $factory = $this->getMockBuilder( BlobStoreFactory::class ) + ->setMethods( [ 'newBlobStore', 'newSqlBlobStore' ] ) + ->disableOriginalConstructor() + ->getMock(); + $factory->expects( $this->any() ) + ->method( 'newBlobStore' ) + ->willReturn( $blobStore ); + $factory->expects( $this->any() ) + ->method( 'newSqlBlobStore' ) + ->willReturn( $blobStore ); + + $this->setService( 'BlobStoreFactory', $factory ); + + $this->testNewMutableRevisionFromArray( $array ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php new file mode 100644 index 0000000000..9904b3bc65 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php @@ -0,0 +1,179 @@ +getMockLoadBalancerFactory(), + $this->getMockBlobStoreFactory(), + $this->getNameTableStoreFactory(), + $this->getHashWANObjectCache(), + $this->getMockCommentStore(), + ActorMigration::newMigration(), + MIGRATION_OLD, + $this->getMockLoggerSpi(), + true + ); + $this->assertTrue( true ); + } + + public function provideWikiIds() { + yield [ true ]; + yield [ false ]; + yield [ 'somewiki' ]; + yield [ 'somewiki', MIGRATION_OLD , false ]; + yield [ 'somewiki', MIGRATION_NEW , true ]; + } + + /** + * @dataProvider provideWikiIds + */ + public function testGetRevisionStore( + $wikiId, + $mcrMigrationStage = MIGRATION_OLD, + $contentHandlerUseDb = true + ) { + $lbFactory = $this->getMockLoadBalancerFactory(); + $blobStoreFactory = $this->getMockBlobStoreFactory(); + $nameTableStoreFactory = $this->getNameTableStoreFactory(); + $cache = $this->getHashWANObjectCache(); + $commentStore = $this->getMockCommentStore(); + $actorMigration = ActorMigration::newMigration(); + $loggerProvider = $this->getMockLoggerSpi(); + + $factory = new RevisionStoreFactory( + $lbFactory, + $blobStoreFactory, + $nameTableStoreFactory, + $cache, + $commentStore, + $actorMigration, + $mcrMigrationStage, + $loggerProvider, + $contentHandlerUseDb + ); + + $store = $factory->getRevisionStore( $wikiId ); + $wrapper = TestingAccessWrapper::newFromObject( $store ); + + // ensure the correct object type is returned + $this->assertInstanceOf( RevisionStore::class, $store ); + + // ensure the RevisionStore is for the given wikiId + $this->assertSame( $wikiId, $wrapper->wikiId ); + + // ensure all other required services are correctly set + $this->assertSame( $cache, $wrapper->cache ); + $this->assertSame( $commentStore, $wrapper->commentStore ); + $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage ); + $this->assertSame( $actorMigration, $wrapper->actorMigration ); + $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() ); + + $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer ); + $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore ); + $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer + */ + private function getMockLoadBalancer() { + return $this->getMockBuilder( ILoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory + */ + private function getMockLoadBalancerFactory() { + $mock = $this->getMockBuilder( ILBFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'getMainLB' ) + ->willReturnCallback( function () { + return $this->getMockLoadBalancer(); + } ); + + return $mock; + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore + */ + private function getMockSqlBlobStore() { + return $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory + */ + private function getMockBlobStoreFactory() { + $mock = $this->getMockBuilder( BlobStoreFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'newSqlBlobStore' ) + ->willReturnCallback( function () { + return $this->getMockSqlBlobStore(); + } ); + + return $mock; + } + + /** + * @return NameTableStoreFactory + */ + private function getNameTableStoreFactory() { + return new NameTableStoreFactory( + $this->getMockLoadBalancerFactory(), + $this->getHashWANObjectCache(), + new NullLogger() ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore + */ + private function getMockCommentStore() { + return $this->getMockBuilder( CommentStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + private function getHashWANObjectCache() { + return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi + */ + private function getMockLoggerSpi() { + $mock = $this->getMock( LoggerSpi::class ); + + $mock->method( 'getLogger' ) + ->willReturn( new NullLogger() ); + + return $mock; + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionStoreRecordTest.php b/tests/phpunit/includes/Revision/RevisionStoreRecordTest.php new file mode 100644 index 0000000000..f1479da4b5 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionStoreRecordTest.php @@ -0,0 +1,366 @@ +resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $row = [ + 'rev_id' => '7', + 'rev_page' => strval( $title->getArticleID() ), + 'rev_timestamp' => '20200101000000', + 'rev_deleted' => 0, + 'rev_minor_edit' => 0, + 'rev_parent_id' => '5', + 'rev_len' => $slots->computeSize(), + 'rev_sha1' => $slots->computeSha1(), + 'page_latest' => '18', + ]; + + $row = array_merge( $row, $rowOverrides ); + + return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots ); + } + + public function provideConstructor() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $protoRow = [ + 'rev_id' => '7', + 'rev_page' => strval( $title->getArticleID() ), + 'rev_timestamp' => '20200101000000', + 'rev_deleted' => 0, + 'rev_minor_edit' => 0, + 'rev_parent_id' => '5', + 'rev_len' => $slots->computeSize(), + 'rev_sha1' => $slots->computeSha1(), + 'page_latest' => '18', + ]; + + $row = $protoRow; + yield 'all info' => [ + $title, + $user, + $comment, + (object)$row, + $slots, + 'acmewiki' + ]; + + $row = $protoRow; + $row['rev_minor_edit'] = '1'; + $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER ); + + yield 'minor deleted' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + $row['page_latest'] = $row['rev_id']; + + yield 'latest' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + unset( $row['rev_parent'] ); + + yield 'no parent' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + $row['rev_len'] = null; + $row['rev_sha1'] = ''; + + yield 'rev_len is null, rev_sha1 is ""' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + yield 'no length, no hash' => [ + Title::newFromText( 'DummyDoesNotExist' ), + $user, + $comment, + (object)$row, + $slots + ]; + } + + /** + * @dataProvider provideConstructor + * + * @param Title $title + * @param UserIdentity $user + * @param CommentStoreComment $comment + * @param object $row + * @param RevisionSlots $slots + * @param bool $wikiId + */ + public function testConstructorAndGetters( + Title $title, + UserIdentity $user, + CommentStoreComment $comment, + $row, + RevisionSlots $slots, + $wikiId = false + ) { + $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId ); + + $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); + $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' ); + $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' ); + $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' ); + $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' ); + $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' ); + + if ( isset( $row->rev_parent_id ) ) { + $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' ); + } else { + $this->assertSame( 0, $rec->getParentId(), 'getParentId' ); + } + + if ( isset( $row->rev_len ) ) { + $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' ); + } else { + $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' ); + } + + if ( !empty( $row->rev_sha1 ) ) { + $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' ); + } else { + $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' ); + } + + if ( isset( $row->page_latest ) ) { + $this->assertSame( + (int)$row->rev_id === (int)$row->page_latest, + $rec->isCurrent(), + 'isCurrent' + ); + } else { + $this->assertSame( + false, + $rec->isCurrent(), + 'isCurrent' + ); + } + } + + public function provideConstructorFailure() { + $title = Title::newFromText( 'Dummy' ); + $title->resetArticleID( 17 ); + + $user = new UserIdentityValue( 11, 'Tester', 0 ); + + $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); + + $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); + $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); + $slots = new RevisionSlots( [ $main, $aux ] ); + + $protoRow = [ + 'rev_id' => '7', + 'rev_page' => strval( $title->getArticleID() ), + 'rev_timestamp' => '20200101000000', + 'rev_deleted' => 0, + 'rev_minor_edit' => 0, + 'rev_parent_id' => '5', + 'rev_len' => $slots->computeSize(), + 'rev_sha1' => $slots->computeSha1(), + 'page_latest' => '18', + ]; + + yield 'not a row' => [ + $title, + $user, + $comment, + 'not a row', + $slots, + 'acmewiki' + ]; + + $row = $protoRow; + $row['rev_timestamp'] = 'kittens'; + + yield 'bad timestamp' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + $row['rev_page'] = 99; + + yield 'page ID mismatch' => [ + $title, + $user, + $comment, + (object)$row, + $slots + ]; + + $row = $protoRow; + + yield 'bad wiki' => [ + $title, + $user, + $comment, + (object)$row, + $slots, + 12345 + ]; + } + + /** + * @dataProvider provideConstructorFailure + * + * @param Title $title + * @param UserIdentity $user + * @param CommentStoreComment $comment + * @param object $row + * @param RevisionSlots $slots + * @param bool $wikiId + */ + public function testConstructorFailure( + Title $title, + UserIdentity $user, + CommentStoreComment $comment, + $row, + RevisionSlots $slots, + $wikiId = false + ) { + $this->setExpectedException( InvalidArgumentException::class ); + new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId ); + } + + public function provideIsCurrent() { + yield [ + [ + 'rev_id' => 11, + 'page_latest' => 11, + ], + true, + ]; + yield [ + [ + 'rev_id' => 11, + 'page_latest' => 10, + ], + false, + ]; + } + + /** + * @dataProvider provideIsCurrent + */ + public function testIsCurrent( $row, $current ) { + $rev = $this->newRevision( $row ); + + $this->assertSame( $current, $rev->isCurrent(), 'isCurrent()' ); + } + + public function provideGetSlot_audience_latest() { + return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT ); + } + + /** + * @dataProvider provideGetSlot_audience_latest + */ + public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) { + $this->forceStandardPermissions(); + + $user = $this->getTestUser( $groups )->getUser(); + $rev = $this->newRevision( + [ + 'rev_deleted' => $visibility, + 'rev_id' => 11, + 'page_latest' => 11, // revision is current + ] + ); + + // NOTE: slot meta-data is never suppressed, just the content is! + $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' ); + $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ), + 'public can' ); + + $this->assertNotNull( + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ), + 'user can' + ); + + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getContent(); + // NOTE: the content of the current revision is never suppressed! + // Check that getContent() doesn't throw SuppressedDataException + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent(); + $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent(); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionStoreTest.php b/tests/phpunit/includes/Revision/RevisionStoreTest.php new file mode 100644 index 0000000000..2093b41faa --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionStoreTest.php @@ -0,0 +1,566 @@ +getMockLoadBalancer(), + $blobStore ?: $this->getMockSqlBlobStore(), + $WANObjectCache ?: $this->getHashWANObjectCache(), + MediaWikiServices::getInstance()->getCommentStore(), + MediaWikiServices::getInstance()->getContentModelStore(), + MediaWikiServices::getInstance()->getSlotRoleStore(), + $wgMultiContentRevisionSchemaMigrationStage, + MediaWikiServices::getInstance()->getActorMigration() + ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer() { + return $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|Database + */ + private function getMockDatabase() { + return $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore + */ + private function getMockSqlBlobStore() { + return $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore + */ + private function getMockCommentStore() { + return $this->getMockBuilder( CommentStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + private function getHashWANObjectCache() { + return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); + } + + public function provideSetContentHandlerUseDB() { + return [ + // ContentHandlerUseDB can be true of false pre migration. + [ false, SCHEMA_COMPAT_OLD, false ], + [ true, SCHEMA_COMPAT_OLD, false ], + // During and after migration it can not be false... + [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, true ], + [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, true ], + [ false, SCHEMA_COMPAT_NEW, true ], + // ...but it can be true. + [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ], + [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ], + [ true, SCHEMA_COMPAT_NEW, false ], + ]; + } + + /** + * @dataProvider provideSetContentHandlerUseDB + * @covers \MediaWiki\Revision\RevisionStore::getContentHandlerUseDB + * @covers \MediaWiki\Revision\RevisionStore::setContentHandlerUseDB + */ + public function testSetContentHandlerUseDB( $contentHandlerDb, $migrationMode, $expectedFail ) { + if ( $expectedFail ) { + $this->setExpectedException( MWException::class ); + } + + $nameTables = MediaWikiServices::getInstance()->getNameTableStoreFactory(); + + $store = new RevisionStore( + $this->getMockLoadBalancer(), + $this->getMockSqlBlobStore(), + $this->getHashWANObjectCache(), + $this->getMockCommentStore(), + $nameTables->getContentModels(), + $nameTables->getSlotRoles(), + $migrationMode, + MediaWikiServices::getInstance()->getActorMigration() + ); + + $store->setContentHandlerUseDB( $contentHandlerDb ); + $this->assertSame( $contentHandlerDb, $store->getContentHandlerUseDB() ); + } + + public function testGetTitle_successFromPageId() { + $mockLoadBalancer = $this->getMockLoadBalancer(); + // Title calls wfGetDB() so we have to set the main service + $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); + + $db = $this->getMockDatabase(); + // Title calls wfGetDB() which uses a regular Connection + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getConnection' ) + ->willReturn( $db ); + + // First call to Title::newFromID, faking no result (db lag?) + $db->expects( $this->at( 0 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( (object)[ + 'page_namespace' => '1', + 'page_title' => 'Food', + ] ); + + $store = $this->getRevisionStore( $mockLoadBalancer ); + $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); + + $this->assertSame( 1, $title->getNamespace() ); + $this->assertSame( 'Food', $title->getDBkey() ); + } + + public function testGetTitle_successFromPageIdOnFallback() { + $mockLoadBalancer = $this->getMockLoadBalancer(); + // Title calls wfGetDB() so we have to set the main service + $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); + + $db = $this->getMockDatabase(); + // Title calls wfGetDB() which uses a regular Connection + // Assert that the first call uses a REPLICA and the second falls back to master + $mockLoadBalancer->expects( $this->exactly( 2 ) ) + ->method( 'getConnection' ) + ->willReturn( $db ); + // RevisionStore getTitle uses a ConnectionRef + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getConnectionRef' ) + ->willReturn( $db ); + + // First call to Title::newFromID, faking no result (db lag?) + $db->expects( $this->at( 0 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( false ); + + // First select using rev_id, faking no result (db lag?) + $db->expects( $this->at( 1 ) ) + ->method( 'selectRow' ) + ->with( + [ 'revision', 'page' ], + $this->anything(), + [ 'rev_id' => 2 ] + ) + ->willReturn( false ); + + // Second call to Title::newFromID, no result + $db->expects( $this->at( 2 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( (object)[ + 'page_namespace' => '2', + 'page_title' => 'Foodey', + ] ); + + $store = $this->getRevisionStore( $mockLoadBalancer ); + $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); + + $this->assertSame( 2, $title->getNamespace() ); + $this->assertSame( 'Foodey', $title->getDBkey() ); + } + + public function testGetTitle_successFromRevId() { + $mockLoadBalancer = $this->getMockLoadBalancer(); + // Title calls wfGetDB() so we have to set the main service + $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); + + $db = $this->getMockDatabase(); + // Title calls wfGetDB() which uses a regular Connection + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getConnection' ) + ->willReturn( $db ); + // RevisionStore getTitle uses a ConnectionRef + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getConnectionRef' ) + ->willReturn( $db ); + + // First call to Title::newFromID, faking no result (db lag?) + $db->expects( $this->at( 0 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( false ); + + // First select using rev_id, faking no result (db lag?) + $db->expects( $this->at( 1 ) ) + ->method( 'selectRow' ) + ->with( + [ 'revision', 'page' ], + $this->anything(), + [ 'rev_id' => 2 ] + ) + ->willReturn( (object)[ + 'page_namespace' => '1', + 'page_title' => 'Food2', + ] ); + + $store = $this->getRevisionStore( $mockLoadBalancer ); + $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); + + $this->assertSame( 1, $title->getNamespace() ); + $this->assertSame( 'Food2', $title->getDBkey() ); + } + + public function testGetTitle_successFromRevIdOnFallback() { + $mockLoadBalancer = $this->getMockLoadBalancer(); + // Title calls wfGetDB() so we have to set the main service + $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); + + $db = $this->getMockDatabase(); + // Title calls wfGetDB() which uses a regular Connection + // Assert that the first call uses a REPLICA and the second falls back to master + $mockLoadBalancer->expects( $this->exactly( 2 ) ) + ->method( 'getConnection' ) + ->willReturn( $db ); + // RevisionStore getTitle uses a ConnectionRef + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getConnectionRef' ) + ->willReturn( $db ); + + // First call to Title::newFromID, faking no result (db lag?) + $db->expects( $this->at( 0 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( false ); + + // First select using rev_id, faking no result (db lag?) + $db->expects( $this->at( 1 ) ) + ->method( 'selectRow' ) + ->with( + [ 'revision', 'page' ], + $this->anything(), + [ 'rev_id' => 2 ] + ) + ->willReturn( false ); + + // Second call to Title::newFromID, no result + $db->expects( $this->at( 2 ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( false ); + + // Second select using rev_id, result + $db->expects( $this->at( 3 ) ) + ->method( 'selectRow' ) + ->with( + [ 'revision', 'page' ], + $this->anything(), + [ 'rev_id' => 2 ] + ) + ->willReturn( (object)[ + 'page_namespace' => '2', + 'page_title' => 'Foodey', + ] ); + + $store = $this->getRevisionStore( $mockLoadBalancer ); + $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); + + $this->assertSame( 2, $title->getNamespace() ); + $this->assertSame( 'Foodey', $title->getDBkey() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::getTitle + */ + public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() { + $mockLoadBalancer = $this->getMockLoadBalancer(); + // Title calls wfGetDB() so we have to set the main service + $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); + + $db = $this->getMockDatabase(); + // Title calls wfGetDB() which uses a regular Connection + // Assert that the first call uses a REPLICA and the second falls back to master + + // RevisionStore getTitle uses getConnectionRef + // Title::newFromID uses getConnection + foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) { + $mockLoadBalancer->expects( $this->exactly( 2 ) ) + ->method( $method ) + ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) { + static $callCounter = 0; + $callCounter++; + // The first call should be to a REPLICA, and the second a MASTER. + if ( $callCounter === 1 ) { + $this->assertSame( DB_REPLICA, $masterOrReplica ); + } elseif ( $callCounter === 2 ) { + $this->assertSame( DB_MASTER, $masterOrReplica ); + } + return $db; + } ); + } + // First and third call to Title::newFromID, faking no result + foreach ( [ 0, 2 ] as $counter ) { + $db->expects( $this->at( $counter ) ) + ->method( 'selectRow' ) + ->with( + 'page', + $this->anything(), + [ 'page_id' => 1 ] + ) + ->willReturn( false ); + } + + foreach ( [ 1, 3 ] as $counter ) { + $db->expects( $this->at( $counter ) ) + ->method( 'selectRow' ) + ->with( + [ 'revision', 'page' ], + $this->anything(), + [ 'rev_id' => 2 ] + ) + ->willReturn( false ); + } + + $store = $this->getRevisionStore( $mockLoadBalancer ); + + $this->setExpectedException( RevisionAccessException::class ); + $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); + } + + public function provideNewRevisionFromRow_legacyEncoding_applied() { + yield 'windows-1252, old_flags is empty' => [ + 'windows-1252', + 'en', + [ + 'old_flags' => '', + 'old_text' => "S\xF6me Content", + ], + 'Söme Content' + ]; + + yield 'windows-1252, old_flags is null' => [ + 'windows-1252', + 'en', + [ + 'old_flags' => null, + 'old_text' => "S\xF6me Content", + ], + 'Söme Content' + ]; + } + + /** + * @dataProvider provideNewRevisionFromRow_legacyEncoding_applied + * + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) { + if ( !$this->useTextId() ) { + $this->markTestSkipped( 'No longer applicable with MCR schema' ); + } + + $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + + $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) ); + + $store = $this->getRevisionStore( $lb, $blobStore, $cache ); + + $record = $store->newRevisionFromRow( + $this->makeRow( $row ), + 0, + Title::newFromText( __METHOD__ . '-UTPage' ) + ); + + $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + */ + public function testNewRevisionFromRow_legacyEncoding_ignored() { + if ( !$this->useTextId() ) { + $this->markTestSkipped( 'No longer applicable with MCR schema' ); + } + + $row = [ + 'old_flags' => 'utf-8', + 'old_text' => 'Söme Content', + ]; + + $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + + $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); + + $store = $this->getRevisionStore( $lb, $blobStore, $cache ); + + $record = $store->newRevisionFromRow( + $this->makeRow( $row ), + 0, + Title::newFromText( __METHOD__ . '-UTPage' ) + ); + $this->assertSame( 'Söme Content', $record->getContent( SlotRecord::MAIN )->serialize() ); + } + + private function makeRow( array $array ) { + $row = $array + [ + 'rev_id' => 7, + 'rev_page' => 5, + 'rev_timestamp' => '20110101000000', + 'rev_user_text' => 'Tester', + 'rev_user' => 17, + 'rev_minor_edit' => 0, + 'rev_deleted' => 0, + 'rev_len' => 100, + 'rev_parent_id' => 0, + 'rev_sha1' => 'deadbeef', + 'rev_comment_text' => 'Testing', + 'rev_comment_data' => '{}', + 'rev_comment_cid' => 111, + 'page_namespace' => 0, + 'page_title' => 'TEST', + 'page_id' => 5, + 'page_latest' => 7, + 'page_is_redirect' => 0, + 'page_len' => 100, + 'user_name' => 'Tester', + ]; + + if ( $this->useTextId() ) { + $row += [ + 'rev_content_format' => CONTENT_FORMAT_TEXT, + 'rev_content_model' => CONTENT_MODEL_TEXT, + 'rev_text_id' => 11, + 'old_id' => 11, + 'old_text' => 'Hello World', + 'old_flags' => 'utf-8', + ]; + } else { + if ( !isset( $row['content'] ) && isset( $array['old_text'] ) ) { + $row['content'] = [ + 'main' => new WikitextContent( $array['old_text'] ), + ]; + } + } + + return (object)$row; + } + + public function provideMigrationConstruction() { + return [ + [ SCHEMA_COMPAT_OLD, false ], + [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ], + [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ], + [ SCHEMA_COMPAT_NEW, false ], + [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_BOTH, true ], + [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, true ], + [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_BOTH, true ], + ]; + } + + /** + * @covers \MediaWiki\Revision\RevisionStore::__construct + * @dataProvider provideMigrationConstruction + */ + public function testMigrationConstruction( $migration, $expectException ) { + if ( $expectException ) { + $this->setExpectedException( InvalidArgumentException::class ); + } + $loadBalancer = $this->getMockLoadBalancer(); + $blobStore = $this->getMockSqlBlobStore(); + $cache = $this->getHashWANObjectCache(); + $commentStore = $this->getMockCommentStore(); + $services = MediaWikiServices::getInstance(); + $nameTables = $services->getNameTableStoreFactory(); + $contentModelStore = $nameTables->getContentModels(); + $slotRoleStore = $nameTables->getSlotRoles(); + $store = new RevisionStore( + $loadBalancer, + $blobStore, + $cache, + $commentStore, + $nameTables->getContentModels(), + $nameTables->getSlotRoles(), + $migration, + $services->getActorMigration() + ); + if ( !$expectException ) { + $store = TestingAccessWrapper::newFromObject( $store ); + $this->assertSame( $loadBalancer, $store->loadBalancer ); + $this->assertSame( $blobStore, $store->blobStore ); + $this->assertSame( $cache, $store->cache ); + $this->assertSame( $commentStore, $store->commentStore ); + $this->assertSame( $contentModelStore, $store->contentModelStore ); + $this->assertSame( $slotRoleStore, $store->slotRoleStore ); + $this->assertSame( $migration, $store->mcrMigrationStage ); + } + } + +} diff --git a/tests/phpunit/includes/Revision/SlotRecordTest.php b/tests/phpunit/includes/Revision/SlotRecordTest.php new file mode 100644 index 0000000000..ea26808355 --- /dev/null +++ b/tests/phpunit/includes/Revision/SlotRecordTest.php @@ -0,0 +1,408 @@ + 1234, + 'slot_content_id' => 33, + 'content_size' => '5', + 'content_sha1' => 'someHash', + 'content_address' => 'tt:456', + 'model_name' => CONTENT_MODEL_WIKITEXT, + 'format_name' => CONTENT_FORMAT_WIKITEXT, + 'slot_revision_id' => '2', + 'slot_origin' => '1', + 'role_name' => 'myRole', + ]; + return (object)$data; + } + + public function testCompleteConstruction() { + $row = $this->makeRow(); + $record = new SlotRecord( $row, new WikitextContent( 'A' ) ); + + $this->assertTrue( $record->hasAddress() ); + $this->assertTrue( $record->hasContentId() ); + $this->assertTrue( $record->hasRevision() ); + $this->assertTrue( $record->isInherited() ); + $this->assertSame( 'A', $record->getContent()->getNativeData() ); + $this->assertSame( 5, $record->getSize() ); + $this->assertSame( 'someHash', $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 1, $record->getOrigin() ); + $this->assertSame( 'tt:456', $record->getAddress() ); + $this->assertSame( 33, $record->getContentId() ); + $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function testConstructionDeferred() { + $row = $this->makeRow( [ + 'content_size' => null, // to be computed + 'content_sha1' => null, // to be computed + 'format_name' => function () { + return CONTENT_FORMAT_WIKITEXT; + }, + 'slot_revision_id' => '2', + 'slot_origin' => '2', + 'slot_content_id' => function () { + return null; + }, + ] ); + + $content = function () { + return new WikitextContent( 'A' ); + }; + + $record = new SlotRecord( $row, $content ); + + $this->assertTrue( $record->hasAddress() ); + $this->assertTrue( $record->hasRevision() ); + $this->assertFalse( $record->hasContentId() ); + $this->assertFalse( $record->isInherited() ); + $this->assertSame( 'A', $record->getContent()->getNativeData() ); + $this->assertSame( 1, $record->getSize() ); + $this->assertNotNull( $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 'tt:456', $record->getAddress() ); + $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function testNewUnsaved() { + $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) ); + + $this->assertFalse( $record->hasAddress() ); + $this->assertFalse( $record->hasContentId() ); + $this->assertFalse( $record->hasRevision() ); + $this->assertFalse( $record->isInherited() ); + $this->assertFalse( $record->hasOrigin() ); + $this->assertSame( 'A', $record->getContent()->getNativeData() ); + $this->assertSame( 1, $record->getSize() ); + $this->assertNotNull( $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function provideInvalidConstruction() { + yield 'both null' => [ null, null ]; + yield 'null row' => [ null, new WikitextContent( 'A' ) ]; + yield 'array row' => [ [], new WikitextContent( 'A' ) ]; + yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ]; + yield 'null content' => [ (object)[], null ]; + } + + /** + * @dataProvider provideInvalidConstruction + */ + public function testInvalidConstruction( $row, $content ) { + $this->setExpectedException( InvalidArgumentException::class ); + new SlotRecord( $row, $content ); + } + + public function testGetContentId_fails() { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getContentId(); + } + + public function testGetAddress_fails() { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getAddress(); + } + + public function provideIncomplete() { + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + yield 'unsaved' => [ $unsaved ]; + + $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); + $inherited = SlotRecord::newInherited( $parent ); + yield 'inherited' => [ $inherited ]; + } + + /** + * @dataProvider provideIncomplete + */ + public function testGetRevision_fails( SlotRecord $record ) { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getRevision(); + } + + /** + * @dataProvider provideIncomplete + */ + public function testGetOrigin_fails( SlotRecord $record ) { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getOrigin(); + } + + public function provideHashStability() { + yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ]; + yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ]; + } + + /** + * @dataProvider provideHashStability + */ + public function testHashStability( $text, $hash ) { + // Changing the output of the hash function will break things horribly! + + $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) ); + + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) ); + $this->assertSame( $hash, $record->getSha1() ); + } + + public function testNewWithSuppressedContent() { + $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); + $output = SlotRecord::newWithSuppressedContent( $input ); + + $this->setExpectedException( SuppressedDataException::class ); + $output->getContent(); + } + + public function testNewInherited() { + $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] ); + $parent = new SlotRecord( $row, new WikitextContent( 'A' ) ); + + // This would happen while doing an edit, before saving revision meta-data. + $inherited = SlotRecord::newInherited( $parent ); + + $this->assertSame( $parent->getContentId(), $inherited->getContentId() ); + $this->assertSame( $parent->getAddress(), $inherited->getAddress() ); + $this->assertSame( $parent->getContent(), $inherited->getContent() ); + $this->assertTrue( $inherited->isInherited() ); + $this->assertTrue( $inherited->hasOrigin() ); + $this->assertFalse( $inherited->hasRevision() ); + + // make sure we didn't mess with the internal state of $parent + $this->assertFalse( $parent->isInherited() ); + $this->assertSame( 7, $parent->getRevision() ); + + // This would happen while doing an edit, after saving the revision meta-data + // and content meta-data. + $saved = SlotRecord::newSaved( + 10, + $inherited->getContentId(), + $inherited->getAddress(), + $inherited + ); + $this->assertSame( $parent->getContentId(), $saved->getContentId() ); + $this->assertSame( $parent->getAddress(), $saved->getAddress() ); + $this->assertSame( $parent->getContent(), $saved->getContent() ); + $this->assertTrue( $saved->isInherited() ); + $this->assertTrue( $saved->hasRevision() ); + $this->assertSame( 10, $saved->getRevision() ); + + // make sure we didn't mess with the internal state of $parent or $inherited + $this->assertSame( 7, $parent->getRevision() ); + $this->assertFalse( $inherited->hasRevision() ); + } + + public function testNewSaved() { + // This would happen while doing an edit, before saving revision meta-data. + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + + // This would happen while doing an edit, after saving the revision meta-data + // and content meta-data. + $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved ); + $this->assertFalse( $saved->isInherited() ); + $this->assertTrue( $saved->hasOrigin() ); + $this->assertTrue( $saved->hasRevision() ); + $this->assertTrue( $saved->hasAddress() ); + $this->assertTrue( $saved->hasContentId() ); + $this->assertSame( 'theNewAddress', $saved->getAddress() ); + $this->assertSame( 20, $saved->getContentId() ); + $this->assertSame( 'A', $saved->getContent()->getNativeData() ); + $this->assertSame( 10, $saved->getRevision() ); + $this->assertSame( 10, $saved->getOrigin() ); + + // make sure we didn't mess with the internal state of $unsaved + $this->assertFalse( $unsaved->hasAddress() ); + $this->assertFalse( $unsaved->hasContentId() ); + $this->assertFalse( $unsaved->hasRevision() ); + } + + public function provideNewSaved_LogicException() { + $freshRow = $this->makeRow( [ + 'content_id' => 10, + 'content_address' => 'address:1', + 'slot_origin' => 1, + 'slot_revision_id' => 1, + ] ); + + $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) ); + yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ]; + yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ]; + yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ]; + + $inheritedRow = $this->makeRow( [ + 'content_id' => null, + 'content_address' => null, + 'slot_origin' => 0, + 'slot_revision_id' => 1, + ] ); + + $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) ); + yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ]; + } + + /** + * @dataProvider provideNewSaved_LogicException + */ + public function testNewSaved_LogicException( + $revisionId, + $contentId, + $contentAddress, + SlotRecord $protoSlot + ) { + $this->setExpectedException( LogicException::class ); + SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); + } + + public function provideNewSaved_InvalidArgumentException() { + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + + yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ]; + yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ]; + yield 'bad content address' => [ 7, 5, 77, $unsaved ]; + } + + /** + * @dataProvider provideNewSaved_InvalidArgumentException + */ + public function testNewSaved_InvalidArgumentException( + $revisionId, + $contentId, + $contentAddress, + SlotRecord $protoSlot + ) { + $this->setExpectedException( InvalidArgumentException::class ); + SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); + } + + public function provideHasSameContent() { + $fail = function () { + self::fail( 'There should be no need to actually load the content.' ); + }; + + $a100a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a100a1b = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a100null = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => null, + ] + ), + $fail + ); + $a100a2 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a2', + ] + ), + $fail + ); + $b100a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'B', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a200a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 200, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a2', + ] + ), + $fail + ); + $a100x1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-x', + 'content_address' => 'xxx:x1', + ] + ), + $fail + ); + + yield 'same instance' => [ $a100a1, $a100a1, true ]; + yield 'no address' => [ $a100a1, $a100null, true ]; + yield 'same address' => [ $a100a1, $a100a1b, true ]; + yield 'different address' => [ $a100a1, $a100a2, true ]; + yield 'different model' => [ $a100a1, $b100a1, false ]; + yield 'different size' => [ $a100a1, $a200a1, false ]; + yield 'different hash' => [ $a100a1, $a100x1, false ]; + } + + /** + * @dataProvider provideHasSameContent + */ + public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) { + $this->assertSame( $sameContent, $a->hasSameContent( $b ) ); + $this->assertSame( $sameContent, $b->hasSameContent( $a ) ); + } + +} diff --git a/tests/phpunit/includes/Revision/create-pre-mcr-fields.sql b/tests/phpunit/includes/Revision/create-pre-mcr-fields.sql new file mode 100644 index 0000000000..09deb4f2cd --- /dev/null +++ b/tests/phpunit/includes/Revision/create-pre-mcr-fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE /*_*/revision ADD rev_text_id INTEGER DEFAULT 0; +ALTER TABLE /*_*/revision ADD rev_content_model VARBINARY(32) DEFAULT NULL; +ALTER TABLE /*_*/revision ADD rev_content_format VARBINARY(64) DEFAULT NULL; diff --git a/tests/phpunit/includes/Revision/drop-mcr-tables.sql b/tests/phpunit/includes/Revision/drop-mcr-tables.sql new file mode 100644 index 0000000000..bc89edc95e --- /dev/null +++ b/tests/phpunit/includes/Revision/drop-mcr-tables.sql @@ -0,0 +1,4 @@ +DROP TABLE /*_*/slots; +DROP TABLE /*_*/content; +DROP TABLE /*_*/content_models; +DROP TABLE /*_*/slot_roles; diff --git a/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sql b/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sql new file mode 100644 index 0000000000..ddfe756f8e --- /dev/null +++ b/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE /*_*/revision DROP COLUMN rev_text_id; +ALTER TABLE /*_*/revision DROP COLUMN rev_content_model; +ALTER TABLE /*_*/revision DROP COLUMN rev_content_format; diff --git a/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sqlite.sql b/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sqlite.sql new file mode 100644 index 0000000000..ce7a61861d --- /dev/null +++ b/tests/phpunit/includes/Revision/drop-pre-mcr-fields.sqlite.sql @@ -0,0 +1,15 @@ +DROP TABLE /*_*/revision; + +CREATE TABLE /*_*/revision ( + rev_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + rev_page INTEGER NOT NULL, + rev_comment BLOB NOT NULL, + rev_user INTEGER NOT NULL default 0, + rev_user_text varchar(255) NOT NULL default '', + rev_timestamp blob(14) NOT NULL default '', + rev_minor_edit INTEGER NOT NULL default 0, + rev_deleted INTEGER NOT NULL default 0, + rev_len INTEGER unsigned, + rev_parent_id INTEGER default NULL, + rev_sha1 varbinary(32) NOT NULL default '' +) /*$wgDBTableOptions*/; diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index 28e6e12f1a..4b444084f3 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -1,10 +1,10 @@ getMockTitle() ); @@ -82,7 +82,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromEmptyArray() { $rev = new Revision( [], 0, $this->getMockTitle() ); @@ -91,7 +91,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromArrayWithBadPageId() { Wikimedia\suppressWarnings(); @@ -131,7 +131,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @dataProvider provideConstructFromArray_userSetAsExpected * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray * * @param array $rowArray * @param mixed $expectedUserId null to expect the current wgUser ID @@ -184,7 +184,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @dataProvider provideConstructFromArrayThrowsExceptions * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromArrayThrowsExceptions( $rowArray, Exception $expectedException ) { $this->setExpectedException( @@ -197,7 +197,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromNothing() { $this->setExpectedException( @@ -268,7 +268,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @dataProvider provideConstructFromRow * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromRow( array $arrayData, callable $assertions ) { $row = (object)$arrayData; @@ -278,7 +278,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray */ public function testConstructFromRowWithBadPageId() { $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); diff --git a/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php b/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php index 18f039d20e..c175e2fd09 100644 --- a/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php +++ b/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php @@ -7,12 +7,12 @@ use Content; use ContentHandler; use LinksUpdate; use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\MutableRevisionSlots; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; 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 MWCallableUpdate; use MWTimestamp; diff --git a/tests/phpunit/includes/Storage/McrReadNewRevisionStoreDbTest.php b/tests/phpunit/includes/Storage/McrReadNewRevisionStoreDbTest.php deleted file mode 100644 index 3b3b3441a1..0000000000 --- a/tests/phpunit/includes/Storage/McrReadNewRevisionStoreDbTest.php +++ /dev/null @@ -1,146 +0,0 @@ -getSlotRoles() ); - - // new schema is written - $this->assertSelect( - 'slots', - [ 'count(*)' ], - [ 'slot_revision_id' => $rev->getId() ], - [ [ (string)$numberOfSlots ] ] - ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revQuery = $store->getSlotsQueryInfo( [ 'content' ] ); - - $this->assertSelect( - $revQuery['tables'], - [ 'count(*)' ], - [ - 'slot_revision_id' => $rev->getId(), - ], - [ [ (string)$numberOfSlots ] ], - [], - $revQuery['joins'] - ); - - // Legacy schema is still being written - $this->assertSelect( - [ 'revision', 'text' ], - [ 'count(*)' ], - [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], - [ [ 1 ] ], - [], - [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] - ); - - parent::assertRevisionExistsInDatabase( $rev ); - } - - /** - * @param SlotRecord $a - * @param SlotRecord $b - */ - protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { - parent::assertSameSlotContent( $a, $b ); - - // Assert that the same content ID has been used - $this->assertSame( $a->getContentId(), $b->getContentId() ); - } - - public function provideInsertRevisionOn_successes() { - foreach ( parent::provideInsertRevisionOn_successes() as $case ) { - yield $case; - } - - yield 'Multi-slot revision insertion' => [ - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'aux' => new TextContent( 'Egg' ), - ], - 'page' => true, - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - ]; - } - - public function provideNewNullRevision() { - foreach ( parent::provideNewNullRevision() as $case ) { - yield $case; - } - - yield [ - Title::newFromText( 'UTPage_notAutoCreated' ), - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'aux' => new WikitextContent( 'Omelet' ), - ], - ], - CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ), - ]; - } - - public function testGetQueryInfo_NoSlotDataJoin() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $queryInfo = $store->getQueryInfo(); - - // with the new schema enabled, query info should not join the main slot info - $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) ); - $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) ); - } - - public function provideNewMutableRevisionFromArray() { - foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { - yield $case; - } - - yield 'Basic array, multiple roles' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 29, - 'parent_id' => 1, - 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii', - 'comment' => 'Goat Comment!', - 'content' => [ - 'main' => new WikitextContent( 'Söme Cöntent' ), - 'aux' => new TextContent( 'Öther Cöntent' ), - ] - ] - ]; - } - -} diff --git a/tests/phpunit/includes/Storage/McrReadNewSchemaOverride.php b/tests/phpunit/includes/Storage/McrReadNewSchemaOverride.php deleted file mode 100644 index 76bd59ad3a..0000000000 --- a/tests/phpunit/includes/Storage/McrReadNewSchemaOverride.php +++ /dev/null @@ -1,57 +0,0 @@ - [], - 'drop' => [], - 'create' => [], - 'alter' => [], - ]; - - if ( !$this->hasMcrTables( $db ) ) { - $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' ); - } - - if ( !$this->hasPreMcrFields( $db ) ) { - $overrides['alter'][] = 'revision'; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ ); - } - - return $overrides; - } - -} diff --git a/tests/phpunit/includes/Storage/McrRevisionStoreDbTest.php b/tests/phpunit/includes/Storage/McrRevisionStoreDbTest.php deleted file mode 100644 index f4fcfb4a13..0000000000 --- a/tests/phpunit/includes/Storage/McrRevisionStoreDbTest.php +++ /dev/null @@ -1,189 +0,0 @@ -getSlotRoles() ); - - // new schema is written - $this->assertSelect( - 'slots', - [ 'count(*)' ], - [ 'slot_revision_id' => $rev->getId() ], - [ [ (string)$numberOfSlots ] ] - ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revQuery = $store->getSlotsQueryInfo( [ 'content' ] ); - - $this->assertSelect( - $revQuery['tables'], - [ 'count(*)' ], - [ - 'slot_revision_id' => $rev->getId(), - ], - [ [ (string)$numberOfSlots ] ], - [], - $revQuery['joins'] - ); - - parent::assertRevisionExistsInDatabase( $rev ); - } - - /** - * @param SlotRecord $a - * @param SlotRecord $b - */ - protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { - parent::assertSameSlotContent( $a, $b ); - - // Assert that the same content ID has been used - $this->assertSame( $a->getContentId(), $b->getContentId() ); - } - - public function provideInsertRevisionOn_successes() { - foreach ( parent::provideInsertRevisionOn_successes() as $case ) { - yield $case; - } - - yield 'Multi-slot revision insertion' => [ - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'aux' => new TextContent( 'Egg' ), - ], - 'page' => true, - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - ]; - } - - public function provideNewNullRevision() { - foreach ( parent::provideNewNullRevision() as $case ) { - yield $case; - } - - yield [ - Title::newFromText( 'UTPage_notAutoCreated' ), - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'aux' => new WikitextContent( 'Omelet' ), - ], - ], - CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ), - ]; - } - - public function provideNewMutableRevisionFromArray() { - foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { - yield $case; - } - - yield 'Basic array, multiple roles' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 29, - 'parent_id' => 1, - 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii', - 'comment' => 'Goat Comment!', - 'content' => [ - 'main' => new WikitextContent( 'Söme Cöntent' ), - 'aux' => new TextContent( 'Öther Cöntent' ), - ] - ] - ]; - } - - public function testGetQueryInfo_NoSlotDataJoin() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $queryInfo = $store->getQueryInfo(); - - // with the new schema enabled, query info should not join the main slot info - $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) ); - $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn - * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn - */ - public function testInsertRevisionOn_T202032() { - // This test only makes sense for MySQL - if ( $this->db->getType() !== 'mysql' ) { - $this->assertTrue( true ); - return; - } - - // NOTE: must be done before checking MAX(rev_id) - $page = $this->getTestPage(); - - $maxRevId = $this->db->selectField( 'revision', 'MAX(rev_id)' ); - - // Construct a slot row that will conflict with the insertion of the next revision ID, - // to emulate the failure mode described in T202032. Nothing will ever read this row, - // we just need it to trigger a primary key conflict. - $this->db->insert( 'slots', [ - 'slot_revision_id' => $maxRevId + 1, - 'slot_role_id' => 1, - 'slot_content_id' => 0, - 'slot_origin' => 0 - ], __METHOD__ ); - - $rev = new MutableRevisionRecord( $page->getTitle() ); - $rev->setTimestamp( '20180101000000' ); - $rev->setComment( CommentStoreComment::newUnsavedComment( 'test' ) ); - $rev->setUser( $this->getTestUser()->getUser() ); - $rev->setContent( 'main', new WikitextContent( 'Text' ) ); - $rev->setPageId( $page->getId() ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $return = $store->insertRevisionOn( $rev, $this->db ); - - $this->assertSame( $maxRevId + 2, $return->getId() ); - - // is the new revision correct? - $this->assertRevisionCompleteness( $return ); - $this->assertRevisionRecordsEqual( $rev, $return ); - - // can we find it directly in the database? - $this->assertRevisionExistsInDatabase( $return ); - - // can we load it from the store? - $loaded = $store->getRevisionById( $return->getId() ); - $this->assertRevisionCompleteness( $loaded ); - $this->assertRevisionRecordsEqual( $return, $loaded ); - } - -} diff --git a/tests/phpunit/includes/Storage/McrSchemaDetection.php b/tests/phpunit/includes/Storage/McrSchemaDetection.php deleted file mode 100644 index c90d42890b..0000000000 --- a/tests/phpunit/includes/Storage/McrSchemaDetection.php +++ /dev/null @@ -1,42 +0,0 @@ -tableExists( 'slots', __METHOD__ ); - } - - /** - * Returns true if pre-MCR fields still exist in the database. - * If yes, the database is compatible with with MIGRATION_OLD mode. - * If hasMcrTables() also returns true, the database supports MIGRATION_WRITE_BOTH mode. - * - * Note that if the database has been updated in MIGRATION_NEW mode, - * the rev_text_id field will be 0 for new revisions. This means that - * in MIGRATION_OLD mode, reading such revisions will fail, even though - * all the necessary fields exist. - * This is not relevant for unit tests, since unit tests reset the database content anyway. - * - * @param IDatabase $db - * @return bool - */ - protected function hasPreMcrFields( IDatabase $db ) { - return $db->fieldExists( 'revision', 'rev_content_model', __METHOD__ ); - } - -} diff --git a/tests/phpunit/includes/Storage/McrSchemaOverride.php b/tests/phpunit/includes/Storage/McrSchemaOverride.php deleted file mode 100644 index d2f58bfa33..0000000000 --- a/tests/phpunit/includes/Storage/McrSchemaOverride.php +++ /dev/null @@ -1,59 +0,0 @@ - [], - 'drop' => [], - 'create' => [], - 'alter' => [], - ]; - - if ( !$this->hasMcrTables( $db ) ) { - $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots.sql' ); - } - - if ( !$this->hasPreMcrFields( $db ) ) { - $overrides['alter'][] = 'revision'; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'drop-pre-mcr-fields', __DIR__ ); - } - - return $overrides; - } - -} diff --git a/tests/phpunit/includes/Storage/McrWriteBothRevisionStoreDbTest.php b/tests/phpunit/includes/Storage/McrWriteBothRevisionStoreDbTest.php deleted file mode 100644 index 10c20b9bc5..0000000000 --- a/tests/phpunit/includes/Storage/McrWriteBothRevisionStoreDbTest.php +++ /dev/null @@ -1,185 +0,0 @@ -rev_text_id = (string)$rev->getTextId(); - $row->rev_content_format = (string)$rev->getContentFormat(); - $row->rev_content_model = (string)$rev->getContentModel(); - - return $row; - } - - protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { - // New schema is being written - $this->assertSelect( - 'slots', - [ 'count(*)' ], - [ 'slot_revision_id' => $rev->getId() ], - [ [ '1' ] ] - ); - - $this->assertSelect( - 'content', - [ 'count(*)' ], - [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ], - [ [ '1' ] ] - ); - - // Legacy schema is still being written - $this->assertSelect( - [ 'revision', 'text' ], - [ 'count(*)' ], - [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], - [ [ 1 ] ], - [], - [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] - ); - - parent::assertRevisionExistsInDatabase( $rev ); - } - - /** - * @param SlotRecord $a - * @param SlotRecord $b - */ - protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { - parent::assertSameSlotContent( $a, $b ); - - // Assert that the same content ID has been used - if ( $a->hasContentId() && $b->hasContentId() ) { - $this->assertSame( $a->getContentId(), $b->getContentId() ); - } - } - - public function provideInsertRevisionOn_failures() { - foreach ( parent::provideInsertRevisionOn_failures() as $case ) { - yield $case; - } - - yield 'slot that is not main slot' => [ - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'lalala' => new WikitextContent( 'Duck' ), - ], - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - new InvalidArgumentException( 'Only the main slot is supported' ) - ]; - } - - public function provideNewMutableRevisionFromArray() { - foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { - yield $case; - } - - yield 'Basic array, with page & id' => [ - [ - 'id' => 2, - 'page' => 1, - 'text_id' => 2, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'content_format' => 'text/x-wiki', - 'content_model' => 'wikitext', - ] - ]; - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - */ - public function testInsertRevisionFromArchiveRow_unmigratedArchiveRow() { - // The main purpose of this test is to assert that after reading an archive - // row using the old schema it can be inserted into the revision table, - // and a slot row is created based on slot emulated from the old-style archive row, - // when none such slot row exists yet. - - $title = $this->getTestPage()->getTitle(); - - $this->db->insert( - 'text', - [ 'old_text' => 'Just a test', 'old_flags' => 'utf-8' ], - __METHOD__ - ); - - $textId = $this->db->insertId(); - - $row = (object)[ - 'ar_minor_edit' => '0', - 'ar_user' => '0', - 'ar_user_text' => '127.0.0.1', - 'ar_actor' => null, - 'ar_len' => '11', - 'ar_deleted' => '0', - 'ar_rev_id' => 112277, - 'ar_timestamp' => $this->db->timestamp( '20180101000000' ), - 'ar_sha1' => 'deadbeef', - 'ar_page_id' => $title->getArticleID(), - 'ar_comment_text' => 'just a test', - 'ar_comment_data' => null, - 'ar_comment_cid' => null, - 'ar_content_format' => null, - 'ar_content_model' => null, - 'ts_tags' => null, - 'ar_id' => 17, - 'ar_namespace' => $title->getNamespace(), - 'ar_title' => $title->getDBkey(), - 'ar_text_id' => $textId, - 'ar_parent_id' => 112211, - ]; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $rev = $store->newRevisionFromArchiveRow( $row ); - - // re-insert archived revision - $return = $store->insertRevisionOn( $rev, $this->db ); - - // is the new revision correct? - $this->assertRevisionCompleteness( $return ); - $this->assertRevisionRecordsEqual( $rev, $return ); - - // can we load it from the store? - $loaded = $store->getRevisionById( $return->getId() ); - $this->assertNotNull( $loaded ); - $this->assertRevisionCompleteness( $loaded ); - $this->assertRevisionRecordsEqual( $return, $loaded ); - - // can we find it directly in the database? - $this->assertRevisionExistsInDatabase( $return ); - } - -} diff --git a/tests/phpunit/includes/Storage/McrWriteBothSchemaOverride.php b/tests/phpunit/includes/Storage/McrWriteBothSchemaOverride.php deleted file mode 100644 index cdcba4f7b4..0000000000 --- a/tests/phpunit/includes/Storage/McrWriteBothSchemaOverride.php +++ /dev/null @@ -1,57 +0,0 @@ - [], - 'drop' => [], - 'create' => [], - 'alter' => [], - ]; - - if ( !$this->hasMcrTables( $db ) ) { - $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' ); - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' ); - } - - if ( !$this->hasPreMcrFields( $db ) ) { - $overrides['alter'][] = 'revision'; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ ); - } - - return $overrides; - } - -} diff --git a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php deleted file mode 100644 index 3e91df43d0..0000000000 --- a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php +++ /dev/null @@ -1,347 +0,0 @@ -resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $record = new MutableRevisionRecord( $title ); - - if ( isset( $rowOverrides['rev_deleted'] ) ) { - $record->setVisibility( $rowOverrides['rev_deleted'] ); - } - - if ( isset( $rowOverrides['rev_id'] ) ) { - $record->setId( $rowOverrides['rev_id'] ); - } - - if ( isset( $rowOverrides['rev_page'] ) ) { - $record->setPageId( $rowOverrides['rev_page'] ); - } - - $record->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $record->setComment( $comment ); - $record->setUser( $user ); - $record->setTimestamp( '20101010000000' ); - - return $record; - } - - public function provideConstructor() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - yield [ - $title, - 'acmewiki' - ]; - } - - /** - * @dataProvider provideConstructor - * - * @param Title $title - * @param bool $wikiId - */ - public function testConstructorAndGetters( - Title $title, - $wikiId = false - ) { - $rec = new MutableRevisionRecord( $title, $wikiId ); - - $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); - $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' ); - } - - public function provideConstructorFailure() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - yield 'not a wiki id' => [ - $title, - null - ]; - } - - /** - * @dataProvider provideConstructorFailure - * - * @param Title $title - * @param bool $wikiId - */ - public function testConstructorFailure( - Title $title, - $wikiId = false - ) { - $this->setExpectedException( InvalidArgumentException::class ); - new MutableRevisionRecord( $title, $wikiId ); - } - - public function testSetGetId() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertNull( $record->getId() ); - $record->setId( 888 ); - $this->assertSame( 888, $record->getId() ); - } - - public function testSetGetUser() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $user = $this->getTestSysop()->getUser(); - $this->assertNull( $record->getUser() ); - $record->setUser( $user ); - $this->assertSame( $user, $record->getUser() ); - } - - public function testSetGetPageId() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertSame( 0, $record->getPageId() ); - $record->setPageId( 999 ); - $this->assertSame( 999, $record->getPageId() ); - } - - public function testSetGetParentId() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertNull( $record->getParentId() ); - $record->setParentId( 100 ); - $this->assertSame( 100, $record->getParentId() ); - } - - public function testGetMainContentWhenEmpty() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->setExpectedException( RevisionAccessException::class ); - $this->assertNull( $record->getContent( SlotRecord::MAIN ) ); - } - - public function testSetGetMainContent() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $content = new WikitextContent( 'Badger' ); - $record->setContent( SlotRecord::MAIN, $content ); - $this->assertSame( $content, $record->getContent( SlotRecord::MAIN ) ); - } - - public function testGetSlotWhenEmpty() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertFalse( $record->hasSlot( SlotRecord::MAIN ) ); - - $this->setExpectedException( RevisionAccessException::class ); - $record->getSlot( SlotRecord::MAIN ); - } - - public function testSetGetSlot() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $slot = SlotRecord::newUnsaved( - SlotRecord::MAIN, - new WikitextContent( 'x' ) - ); - $record->setSlot( $slot ); - $this->assertTrue( $record->hasSlot( SlotRecord::MAIN ) ); - $this->assertSame( $slot, $record->getSlot( SlotRecord::MAIN ) ); - } - - public function testSetGetMinor() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertFalse( $record->isMinor() ); - $record->setMinorEdit( true ); - $this->assertSame( true, $record->isMinor() ); - } - - public function testSetGetTimestamp() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertNull( $record->getTimestamp() ); - $record->setTimestamp( '20180101010101' ); - $this->assertSame( '20180101010101', $record->getTimestamp() ); - } - - public function testSetGetVisibility() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertSame( 0, $record->getVisibility() ); - $record->setVisibility( RevisionRecord::DELETED_USER ); - $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() ); - } - - public function testSetGetSha1() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() ); - $record->setSha1( 'someHash' ); - $this->assertSame( 'someHash', $record->getSha1() ); - } - - public function testGetSlots() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() ); - } - - public function testSetGetSize() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $this->assertSame( 0, $record->getSize() ); - $record->setSize( 775 ); - $this->assertSame( 775, $record->getSize() ); - } - - public function testSetGetComment() { - $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); - $comment = new CommentStoreComment( 1, 'foo' ); - $this->assertNull( $record->getComment() ); - $record->setComment( $comment ); - $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( SlotRecord::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' ) ); - } - - public function provideNotReadyForInsertion() { - /** @var Title $title */ - $title = $this->getMock( Title::class ); - - /** @var User $user */ - $user = $this->getMock( User::class ); - - /** @var CommentStoreComment $comment */ - $comment = $this->getMockBuilder( CommentStoreComment::class ) - ->disableOriginalConstructor() - ->getMock(); - - $content = new TextContent( 'Test' ); - - $rev = new MutableRevisionRecord( $title ); - yield 'empty' => [ $rev ]; - - $rev = new MutableRevisionRecord( $title ); - $rev->setContent( SlotRecord::MAIN, $content ); - $rev->setUser( $user ); - $rev->setComment( $comment ); - yield 'no timestamp' => [ $rev ]; - - $rev = new MutableRevisionRecord( $title ); - $rev->setUser( $user ); - $rev->setComment( $comment ); - $rev->setTimestamp( '20101010000000' ); - yield 'no content' => [ $rev ]; - - $rev = new MutableRevisionRecord( $title ); - $rev->setContent( SlotRecord::MAIN, $content ); - $rev->setComment( $comment ); - $rev->setTimestamp( '20101010000000' ); - yield 'no user' => [ $rev ]; - - $rev = new MutableRevisionRecord( $title ); - $rev->setUser( $user ); - $rev->setContent( SlotRecord::MAIN, $content ); - $rev->setTimestamp( '20101010000000' ); - yield 'no comment' => [ $rev ]; - } - - /** - * @dataProvider provideNotReadyForInsertion - */ - public function testNotReadyForInsertion( $rev ) { - $this->assertFalse( $rev->isReadyForInsertion() ); - } -} diff --git a/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php b/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php deleted file mode 100644 index 1ef0121211..0000000000 --- a/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php +++ /dev/null @@ -1,148 +0,0 @@ - [ - [ 1, 2, 3 ] - ]; - } - - /** - * @dataProvider provideConstructorFailue - * @param $slots - * - * @covers \MediaWiki\Storage\RevisionSlots::__construct - * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal - */ - public function testConstructorFailue( $slots ) { - $this->setExpectedException( InvalidArgumentException::class ); - - new MutableRevisionSlots( $slots ); - } - - public function testSetMultipleSlots() { - $slots = new MutableRevisionSlots(); - - $this->assertSame( [], $slots->getSlots() ); - - $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) ); - $slots->setSlot( $slotA ); - $this->assertTrue( $slots->hasSlot( 'some' ) ); - $this->assertSame( $slotA, $slots->getSlot( 'some' ) ); - $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() ); - - $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) ); - $slots->setSlot( $slotB ); - $this->assertTrue( $slots->hasSlot( 'other' ) ); - $this->assertSame( $slotB, $slots->getSlot( 'other' ) ); - $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() ); - } - - public function testSetExistingSlotOverwritesSlot() { - $slots = new MutableRevisionSlots(); - - $this->assertSame( [], $slots->getSlots() ); - - $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $slots->setSlot( $slotA ); - $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) ); - $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); - - $slotB = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'B' ) ); - $slots->setSlot( $slotB ); - $this->assertSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) ); - $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( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $slots->setSlot( $slotA ); - $slotB = $this->newSavedSlot( SlotRecord::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( SlotRecord::MAIN ) ); - $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) ); - $this->assertTrue( $slots->getSlot( SlotRecord::MAIN )->isInherited() ); - $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() ); - $this->assertSame( $slotB->getContent(), $slots->getSlot( SlotRecord::MAIN )->getContent() ); - $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() ); - } - - public function testSetContentOfExistingSlotOverwritesContent() { - $slots = new MutableRevisionSlots(); - - $this->assertSame( [], $slots->getSlots() ); - - $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $slots->setSlot( $slotA ); - $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) ); - $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); - - $newContent = new WikitextContent( 'B' ); - $slots->setContent( SlotRecord::MAIN, $newContent ); - $this->assertSame( $newContent, $slots->getContent( SlotRecord::MAIN ) ); - } - - public function testRemoveExistingSlot() { - $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $slots = new MutableRevisionSlots( [ $slotA ] ); - - $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() ); - - $slots->removeSlot( SlotRecord::MAIN ); - $this->assertSame( [], $slots->getSlots() ); - $this->setExpectedException( RevisionAccessException::class ); - $slots->getSlot( SlotRecord::MAIN ); - } - - public function testNewFromParentRevisionSlots() { - /** @var SlotRecord[] $parentSlots */ - $parentSlots = [ - 'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ), - 'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ), - ]; - $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots ); - $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() ); - $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) ); - $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) ); - $this->assertTrue( $slots->getSlot( 'some' )->isInherited() ); - $this->assertTrue( $slots->getSlot( 'other' )->isInherited() ); - $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) ); - $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) ); - } - -} diff --git a/tests/phpunit/includes/Storage/NoContentModelRevisionStoreDbTest.php b/tests/phpunit/includes/Storage/NoContentModelRevisionStoreDbTest.php deleted file mode 100644 index 7e1e1ee919..0000000000 --- a/tests/phpunit/includes/Storage/NoContentModelRevisionStoreDbTest.php +++ /dev/null @@ -1,191 +0,0 @@ -rev_text_id = (string)$rev->getTextId(); - - return $row; - } - - public function provideGetArchiveQueryInfo() { - yield [ - [ - 'tables' => [ 'archive' ], - 'fields' => array_merge( - $this->getDefaultArchiveFields(), - [ - 'ar_comment_text' => 'ar_comment', - 'ar_comment_data' => 'NULL', - 'ar_comment_cid' => 'NULL', - 'ar_user_text' => 'ar_user_text', - 'ar_user' => 'ar_user', - 'ar_actor' => 'NULL', - ] - ), - 'joins' => [], - ] - ]; - } - - public function provideGetQueryInfo() { - yield [ - [], - [ - 'tables' => [ 'revision' ], - 'fields' => array_merge( - $this->getDefaultQueryFields(), - $this->getCommentQueryFields(), - $this->getActorQueryFields() - ), - 'joins' => [], - ] - ]; - yield [ - [ 'page' ], - [ - 'tables' => [ 'revision', 'page' ], - 'fields' => array_merge( - $this->getDefaultQueryFields(), - $this->getCommentQueryFields(), - $this->getActorQueryFields(), - [ - 'page_namespace', - 'page_title', - 'page_id', - 'page_latest', - 'page_is_redirect', - 'page_len', - ] - ), - 'joins' => [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - ], - ] - ]; - yield [ - [ 'user' ], - [ - 'tables' => [ 'revision', 'user' ], - 'fields' => array_merge( - $this->getDefaultQueryFields(), - $this->getCommentQueryFields(), - $this->getActorQueryFields(), - [ - 'user_name', - ] - ), - 'joins' => [ - 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], - ], - ] - ]; - yield [ - [ 'text' ], - [ - 'tables' => [ 'revision', 'text' ], - 'fields' => array_merge( - $this->getDefaultQueryFields(), - $this->getCommentQueryFields(), - $this->getActorQueryFields(), - [ - 'old_text', - 'old_flags', - ] - ), - 'joins' => [ - 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ], - ], - ] - ]; - } - - public function provideGetSlotsQueryInfo() { - $db = wfGetDB( DB_REPLICA ); - - yield [ - [], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - ] - ), - 'joins' => [], - ] - ]; - yield [ - [ 'content' ], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', - 'content_address' => - $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'NULL', - ] - ), - 'joins' => [], - ] - ]; - } - - public function provideNewMutableRevisionFromArray() { - foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { - yield $case; - } - - yield 'Basic array, with page & id' => [ - [ - 'id' => 2, - 'page' => 1, - 'text_id' => 2, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - ] - ]; - } - -} diff --git a/tests/phpunit/includes/Storage/PageUpdaterTest.php b/tests/phpunit/includes/Storage/PageUpdaterTest.php index 393398622c..3ba90327ec 100644 --- a/tests/phpunit/includes/Storage/PageUpdaterTest.php +++ b/tests/phpunit/includes/Storage/PageUpdaterTest.php @@ -5,8 +5,8 @@ namespace MediaWiki\Tests\Storage; use CommentStoreComment; use Content; use MediaWiki\MediaWikiServices; -use MediaWiki\Storage\RevisionRecord; -use MediaWiki\Storage\SlotRecord; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; use MediaWikiTestCase; use ParserOptions; use RecentChange; diff --git a/tests/phpunit/includes/Storage/PreMcrRevisionStoreDbTest.php b/tests/phpunit/includes/Storage/PreMcrRevisionStoreDbTest.php deleted file mode 100644 index 687ad4f309..0000000000 --- a/tests/phpunit/includes/Storage/PreMcrRevisionStoreDbTest.php +++ /dev/null @@ -1,91 +0,0 @@ -rev_text_id = (string)$rev->getTextId(); - $row->rev_content_format = (string)$rev->getContentFormat(); - $row->rev_content_model = (string)$rev->getContentModel(); - - return $row; - } - - protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { - // Legacy schema is still being written - $this->assertSelect( - [ 'revision', 'text' ], - [ 'count(*)' ], - [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ], - [ [ 1 ] ], - [], - [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ] - ); - - parent::assertRevisionExistsInDatabase( $rev ); - } - - public function provideInsertRevisionOn_failures() { - foreach ( parent::provideInsertRevisionOn_failures() as $case ) { - yield $case; - } - - yield 'slot that is not main slot' => [ - [ - 'content' => [ - 'main' => new WikitextContent( 'Chicken' ), - 'lalala' => new WikitextContent( 'Duck' ), - ], - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - new InvalidArgumentException( 'Only the main slot is supported' ) - ]; - } - - public function provideNewMutableRevisionFromArray() { - foreach ( parent::provideNewMutableRevisionFromArray() as $case ) { - yield $case; - } - - yield 'Basic array, with page & id' => [ - [ - 'id' => 2, - 'page' => 1, - 'text_id' => 2, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'content_format' => 'text/x-wiki', - 'content_model' => 'wikitext', - ] - ]; - } - -} diff --git a/tests/phpunit/includes/Storage/PreMcrSchemaOverride.php b/tests/phpunit/includes/Storage/PreMcrSchemaOverride.php deleted file mode 100644 index bb72a573ce..0000000000 --- a/tests/phpunit/includes/Storage/PreMcrSchemaOverride.php +++ /dev/null @@ -1,53 +0,0 @@ - [], - 'drop' => [], - 'create' => [], - 'alter' => [], - ]; - - if ( $this->hasMcrTables( $db ) ) { - $overrides['drop'] = [ 'slots', 'content', 'slot_roles', 'content_models', ]; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/drop-mcr-tables', __DIR__ ); - } - - if ( !$this->hasPreMcrFields( $db ) ) { - $overrides['alter'][] = 'revision'; - $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/create-pre-mcr-fields', __DIR__ ); - } - - return $overrides; - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php b/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php deleted file mode 100644 index fad6228dc2..0000000000 --- a/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php +++ /dev/null @@ -1,272 +0,0 @@ -resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $row = [ - 'ar_id' => '5', - 'ar_rev_id' => '7', - 'ar_page_id' => strval( $title->getArticleID() ), - 'ar_timestamp' => '20200101000000', - 'ar_deleted' => 0, - 'ar_minor_edit' => 0, - 'ar_parent_id' => '5', - 'ar_len' => $slots->computeSize(), - 'ar_sha1' => $slots->computeSha1(), - ]; - - foreach ( $rowOverrides as $field => $value ) { - $field = preg_replace( '/^rev_/', 'ar_', $field ); - $row[$field] = $value; - } - - return new RevisionArchiveRecord( $title, $user, $comment, (object)$row, $slots ); - } - - public function provideConstructor() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $protoRow = [ - 'ar_id' => '5', - 'ar_rev_id' => '7', - 'ar_page_id' => strval( $title->getArticleID() ), - 'ar_timestamp' => '20200101000000', - 'ar_deleted' => 0, - 'ar_minor_edit' => 0, - 'ar_parent_id' => '5', - 'ar_len' => $slots->computeSize(), - 'ar_sha1' => $slots->computeSha1(), - ]; - - $row = $protoRow; - yield 'all info' => [ - $title, - $user, - $comment, - (object)$row, - $slots, - 'acmewiki' - ]; - - $row = $protoRow; - $row['ar_minor_edit'] = '1'; - $row['ar_deleted'] = strval( RevisionRecord::DELETED_USER ); - - yield 'minor deleted' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - unset( $row['ar_parent'] ); - - yield 'no parent' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - $row['ar_len'] = null; - $row['ar_sha1'] = ''; - - yield 'ar_len is null, ar_sha1 is ""' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - yield 'no length, no hash' => [ - Title::newFromText( 'DummyDoesNotExist' ), - $user, - $comment, - (object)$row, - $slots - ]; - } - - /** - * @dataProvider provideConstructor - * - * @param Title $title - * @param UserIdentity $user - * @param CommentStoreComment $comment - * @param object $row - * @param RevisionSlots $slots - * @param bool $wikiId - */ - public function testConstructorAndGetters( - Title $title, - UserIdentity $user, - CommentStoreComment $comment, - $row, - RevisionSlots $slots, - $wikiId = false - ) { - $rec = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId ); - - $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); - $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' ); - $this->assertSame( $comment, $rec->getComment(), 'getComment' ); - - $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' ); - $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' ); - - $this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' ); - $this->assertSame( (int)$row->ar_rev_id, $rec->getId(), 'getId' ); - $this->assertSame( (int)$row->ar_page_id, $rec->getPageId(), 'getId' ); - $this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' ); - $this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' ); - $this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' ); - - if ( isset( $row->ar_parent_id ) ) { - $this->assertSame( (int)$row->ar_parent_id, $rec->getParentId(), 'getParentId' ); - } else { - $this->assertSame( 0, $rec->getParentId(), 'getParentId' ); - } - - if ( isset( $row->ar_len ) ) { - $this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' ); - } else { - $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' ); - } - - if ( !empty( $row->ar_sha1 ) ) { - $this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' ); - } else { - $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' ); - } - } - - public function provideConstructorFailure() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $protoRow = [ - 'ar_id' => '5', - 'ar_rev_id' => '7', - 'ar_page_id' => strval( $title->getArticleID() ), - 'ar_timestamp' => '20200101000000', - 'ar_deleted' => 0, - 'ar_minor_edit' => 0, - 'ar_parent_id' => '5', - 'ar_len' => $slots->computeSize(), - 'ar_sha1' => $slots->computeSha1(), - ]; - - yield 'not a row' => [ - $title, - $user, - $comment, - 'not a row', - $slots, - 'acmewiki' - ]; - - $row = $protoRow; - $row['ar_timestamp'] = 'kittens'; - - yield 'bad timestamp' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - - yield 'bad wiki' => [ - $title, - $user, - $comment, - (object)$row, - $slots, - 12345 - ]; - - // NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases! - } - - /** - * @dataProvider provideConstructorFailure - * - * @param Title $title - * @param UserIdentity $user - * @param CommentStoreComment $comment - * @param object $row - * @param RevisionSlots $slots - * @param bool $wikiId - */ - public function testConstructorFailure( - Title $title, - UserIdentity $user, - CommentStoreComment $comment, - $row, - RevisionSlots $slots, - $wikiId = false - ) { - $this->setExpectedException( InvalidArgumentException::class ); - new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId ); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionQueryInfoTest.php b/tests/phpunit/includes/Storage/RevisionQueryInfoTest.php deleted file mode 100644 index 165c27b0d5..0000000000 --- a/tests/phpunit/includes/Storage/RevisionQueryInfoTest.php +++ /dev/null @@ -1,1178 +0,0 @@ - "{$prefix}_comment", - "{$prefix}_comment_data" => 'NULL', - "{$prefix}_comment_cid" => 'NULL', - ]; - } - - protected function getCompatCommentQueryFields( $prefix ) { - return [ - "{$prefix}_comment_text" - => "COALESCE( comment_{$prefix}_comment.comment_text, {$prefix}_comment )", - "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data", - "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id", - ]; - } - - protected function getNewCommentQueryFields( $prefix ) { - return [ - "{$prefix}_comment_text" => "comment_{$prefix}_comment.comment_text", - "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data", - "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id", - ]; - } - - protected function getOldActorQueryFields( $prefix ) { - return [ - "{$prefix}_user" => "{$prefix}_user", - "{$prefix}_user_text" => "{$prefix}_user_text", - "{$prefix}_actor" => 'NULL', - ]; - } - - protected function getNewActorQueryFields( $prefix, $tmp = false ) { - return [ - "{$prefix}_user" => "actor_{$prefix}_user.actor_user", - "{$prefix}_user_text" => "actor_{$prefix}_user.actor_name", - "{$prefix}_actor" => $tmp ?: "{$prefix}_actor", - ]; - } - - protected function getCompatActorQueryFields( $prefix, $tmp = false ) { - return [ - "{$prefix}_user" => "COALESCE( actor_{$prefix}_user.actor_user, {$prefix}_user )", - "{$prefix}_user_text" => "COALESCE( actor_{$prefix}_user.actor_name, {$prefix}_user_text )", - "{$prefix}_actor" => $tmp ?: "{$prefix}_actor", - ]; - } - - protected function getCompatActorJoins( $prefix ) { - return [ - "temp_{$prefix}_user" => [ - "LEFT JOIN", - "temp_{$prefix}_user.revactor_{$prefix} = {$prefix}_id", - ], - "actor_{$prefix}_user" => [ - "LEFT JOIN", - "actor_{$prefix}_user.actor_id = temp_{$prefix}_user.revactor_actor", - ], - ]; - } - - protected function getCompatCommentJoins( $prefix ) { - return [ - "temp_{$prefix}_comment" => [ - "LEFT JOIN", - "temp_{$prefix}_comment.revcomment_{$prefix} = {$prefix}_id", - ], - "comment_{$prefix}_comment" => [ - "LEFT JOIN", - "comment_{$prefix}_comment.comment_id = temp_{$prefix}_comment.revcomment_comment_id", - ], - ]; - } - - protected function getTextQueryFields() { - return [ - 'old_text', - 'old_flags', - ]; - } - - protected function getPageQueryFields() { - return [ - 'page_namespace', - 'page_title', - 'page_id', - 'page_latest', - 'page_is_redirect', - 'page_len', - ]; - } - - protected function getUserQueryFields() { - return [ - 'user_name', - ]; - } - - protected function getContentHandlerQueryFields( $prefix ) { - return [ - "{$prefix}_content_format", - "{$prefix}_content_model", - ]; - } - - public function provideArchiveQueryInfo() { - yield 'MCR, comment, actor' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW, - 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW, - ], - [ - 'tables' => [ - 'archive', - 'actor_ar_user' => 'actor', - 'comment_ar_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getArchiveQueryFields( false ), - $this->getNewActorQueryFields( 'ar' ), - $this->getNewCommentQueryFields( 'ar' ) - ), - 'joins' => [ - 'comment_ar_comment' - => [ 'JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], - 'actor_ar_user' => [ 'JOIN', 'actor_ar_user.actor_id = ar_actor' ], - ], - ] - ]; - yield 'read-new MCR, comment, actor' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - ], - [ - 'tables' => [ - 'archive', - 'actor_ar_user' => 'actor', - 'comment_ar_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getArchiveQueryFields( false ), - $this->getCompatActorQueryFields( 'ar' ), - $this->getCompatCommentQueryFields( 'ar' ) - ), - 'joins' => [ - 'comment_ar_comment' - => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], - 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ], - ], - ] - ]; - yield 'MCR write-both/read-old' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - ], - [ - 'tables' => [ - 'archive', - 'actor_ar_user' => 'actor', - 'comment_ar_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getArchiveQueryFields( true ), - $this->getContentHandlerQueryFields( 'ar' ), - $this->getCompatActorQueryFields( 'ar' ), - $this->getCompatCommentQueryFields( 'ar' ) - ), - 'joins' => [ - 'comment_ar_comment' - => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ], - 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ], - ], - ] - ]; - yield 'pre-MCR, no model' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ - 'tables' => [ - 'archive', - ], - 'fields' => array_merge( - $this->getArchiveQueryFields( true ), - $this->getOldActorQueryFields( 'ar' ), - $this->getOldCommentQueryFields( 'ar' ) - ), - 'joins' => [], - ] - ]; - } - - public function provideQueryInfo() { - // TODO: more option variations - yield 'MCR, page, user, comment, actor' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW, - 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW, - ], - [ 'page', 'user' ], - [ - 'tables' => [ - 'revision', - 'page', - 'user', - 'temp_rev_user' => 'revision_actor_temp', - 'temp_rev_comment' => 'revision_comment_temp', - 'actor_rev_user' => 'actor', - 'comment_rev_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( false ), - $this->getPageQueryFields(), - $this->getUserQueryFields(), - $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), - $this->getNewCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - 'user' => [ - 'LEFT JOIN', - [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ], - ], - 'comment_rev_comment' => [ - 'JOIN', - 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id', - ], - 'actor_rev_user' => [ - 'JOIN', - 'actor_rev_user.actor_id = temp_rev_user.revactor_actor', - ], - 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ], - 'temp_rev_comment' => [ 'JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ], - ], - ] - ]; - yield 'MCR read-new, page, user, comment, actor' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - ], - [ 'page', 'user' ], - [ - 'tables' => [ - 'revision', - 'page', - 'user', - 'temp_rev_user' => 'revision_actor_temp', - 'temp_rev_comment' => 'revision_comment_temp', - 'actor_rev_user' => 'actor', - 'comment_rev_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( false ), - $this->getPageQueryFields(), - $this->getUserQueryFields(), - $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), - $this->getCompatCommentQueryFields( 'rev' ) - ), - 'joins' => array_merge( - [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - 'user' => [ - 'LEFT JOIN', - [ - 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', - 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' - ] - ], - ], - $this->getCompatActorJoins( 'rev' ), - $this->getCompatCommentJoins( 'rev' ) - ), - ] - ]; - yield 'MCR read-new' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW, - ], - [ 'page', 'user' ], - [ - 'tables' => [ - 'revision', - 'page', - 'user', - 'temp_rev_user' => 'revision_actor_temp', - 'temp_rev_comment' => 'revision_comment_temp', - 'actor_rev_user' => 'actor', - 'comment_rev_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( false ), - $this->getPageQueryFields(), - $this->getUserQueryFields(), - $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), - $this->getCompatCommentQueryFields( 'rev' ) - ), - 'joins' => array_merge( - [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - 'user' => [ - 'LEFT JOIN', - [ - 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', - 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' - ] - ], - ], - $this->getCompatActorJoins( 'rev' ), - $this->getCompatCommentJoins( 'rev' ) - ), - ] - ]; - yield 'MCR write-both/read-old' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - ], - [], - [ - 'tables' => [ - 'revision', - 'temp_rev_user' => 'revision_actor_temp', - 'temp_rev_comment' => 'revision_comment_temp', - 'actor_rev_user' => 'actor', - 'comment_rev_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getContentHandlerQueryFields( 'rev' ), - $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), - $this->getCompatCommentQueryFields( 'rev' ) - ), - 'joins' => array_merge( - $this->getCompatActorJoins( 'rev' ), - $this->getCompatCommentJoins( 'rev' ) - ), - ] - ]; - yield 'MCR write-both/read-old, page, user' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - ], - [ 'page', 'user' ], - [ - 'tables' => [ - 'revision', - 'page', - 'user', - 'temp_rev_user' => 'revision_actor_temp', - 'temp_rev_comment' => 'revision_comment_temp', - 'actor_rev_user' => 'actor', - 'comment_rev_comment' => 'comment', - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getContentHandlerQueryFields( 'rev' ), - $this->getUserQueryFields(), - $this->getPageQueryFields(), - $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ), - $this->getCompatCommentQueryFields( 'rev' ) - ), - 'joins' => array_merge( - [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - 'user' => [ - 'LEFT JOIN', - [ - 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0', - 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )' - ] - ], - ], - $this->getCompatActorJoins( 'rev' ), - $this->getCompatCommentJoins( 'rev' ) - ), - ] - ]; - yield 'pre-MCR' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [], - [ - 'tables' => [ 'revision' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getContentHandlerQueryFields( 'rev' ), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [], - ] - ]; - yield 'pre-MCR, page, user' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ 'page', 'user' ], - [ - 'tables' => [ 'revision', 'page', 'user' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getContentHandlerQueryFields( 'rev' ), - $this->getPageQueryFields(), - $this->getUserQueryFields(), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], - ], - ] - ]; - yield 'pre-MCR, no model' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [], - [ - 'tables' => [ 'revision' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [], - ], - ]; - yield 'pre-MCR, no model, page' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ 'page' ], - [ - 'tables' => [ 'revision', 'page' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getPageQueryFields(), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ], ], - ], - ], - ]; - yield 'pre-MCR, no model, user' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ 'user' ], - [ - 'tables' => [ 'revision', 'user' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getUserQueryFields(), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], - ], - ], - ]; - yield 'pre-MCR, no model, text' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ 'text' ], - [ - 'tables' => [ 'revision', 'text' ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getTextQueryFields(), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ], - ], - ], - ]; - yield 'pre-MCR, no model, text, page, user' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - [ 'text', 'page', 'user' ], - [ - 'tables' => [ - 'revision', 'page', 'user', 'text' - ], - 'fields' => array_merge( - $this->getRevisionQueryFields( true ), - $this->getPageQueryFields(), - $this->getUserQueryFields(), - $this->getTextQueryFields(), - $this->getOldActorQueryFields( 'rev' ), - $this->getOldCommentQueryFields( 'rev' ) - ), - 'joins' => [ - 'page' => [ - 'INNER JOIN', - [ 'page_id = rev_page' ], - ], - 'user' => [ - 'LEFT JOIN', - [ - 'rev_user != 0', - 'user_id = rev_user', - ], - ], - 'text' => [ - 'INNER JOIN', - [ 'rev_text_id=old_id' ], - ], - ], - ], - ]; - } - - public function provideSlotsQueryInfo() { - yield 'MCR, no options' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, - ], - [], - [ - 'tables' => [ - 'slots' - ], - 'fields' => [ - 'slot_revision_id', - 'slot_content_id', - 'slot_origin', - 'slot_role_id', - ], - 'joins' => [], - ] - ]; - yield 'MCR, role option' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW, - ], - [ 'role' ], - [ - 'tables' => [ - 'slots', - 'slot_roles', - ], - 'fields' => [ - 'slot_revision_id', - 'slot_content_id', - 'slot_origin', - 'slot_role_id', - 'role_name', - ], - 'joins' => [ - 'slot_roles' => [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ], - ], - ] - ]; - yield 'MCR read-new, content option' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, - ], - [ 'content' ], - [ - 'tables' => [ - 'slots', - 'content', - ], - 'fields' => [ - 'slot_revision_id', - 'slot_content_id', - 'slot_origin', - 'slot_role_id', - 'content_size', - 'content_sha1', - 'content_address', - 'content_model', - ], - 'joins' => [ - 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ], - ], - ] - ]; - yield 'MCR read-new, content and model options' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, - ], - [ 'content', 'model' ], - [ - 'tables' => [ - 'slots', - 'content', - 'content_models', - ], - 'fields' => [ - 'slot_revision_id', - 'slot_content_id', - 'slot_origin', - 'slot_role_id', - 'content_size', - 'content_sha1', - 'content_address', - 'content_model', - 'model_name', - ], - 'joins' => [ - 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ], - 'content_models' => [ 'LEFT JOIN', [ 'content_model = model_id' ] ], - ], - ] - ]; - - $db = wfGetDB( DB_REPLICA ); - - yield 'MCR write-both/read-old' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - ], - [], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - ] - ), - 'joins' => [], - ] - ]; - yield 'MCR write-both/read-old, content' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - ], - [ 'content' ], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', - 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', - ] - ), - 'joins' => [], - ] - ]; - yield 'MCR write-both/read-old, content, model, role' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, - ], - [ 'content', 'model', 'role' ], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', - 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', - ] - ), - 'joins' => [], - ] - ]; - yield 'pre-MCR' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_OLD, - ], - [], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - ] - ), - 'joins' => [], - ] - ]; - yield 'pre-MCR, content' => [ - [ - 'wgMultiContentRevisionSchemaMigrationStage' - => SCHEMA_COMPAT_OLD, - ], - [ 'content' ], - [ - 'tables' => [ - 'slots' => 'revision', - ], - 'fields' => array_merge( - [ - 'slot_revision_id' => 'slots.rev_id', - 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', - 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', - 'content_address' => - $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', - ] - ), - 'joins' => [], - ] - ]; - } - - public function provideSelectFields() { - yield 'with model, comment, and actor' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - ], - 'fields' => array_merge( - [ - 'rev_id', - 'rev_page', - 'rev_text_id', - 'rev_timestamp', - 'rev_user_text', - 'rev_user', - 'rev_actor' => 'NULL', - 'rev_minor_edit', - 'rev_deleted', - 'rev_len', - 'rev_parent_id', - 'rev_sha1', - ], - $this->getContentHandlerQueryFields( 'rev' ), - [ - 'rev_comment_old' => 'rev_comment', - 'rev_comment_pk' => 'rev_id', - ] - ), - ]; - yield 'no mode, no comment, no actor' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - 'fields' => array_merge( - [ - 'rev_id', - 'rev_page', - 'rev_text_id', - 'rev_timestamp', - 'rev_user_text', - 'rev_user', - 'rev_actor' => 'NULL', - 'rev_minor_edit', - 'rev_deleted', - 'rev_len', - 'rev_parent_id', - 'rev_sha1', - ], - $this->getOldCommentQueryFields( 'rev' ) - ), - ]; - } - - public function provideSelectArchiveFields() { - yield 'with model, comment, and actor' => [ - [ - 'wgContentHandlerUseDB' => true, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, - ], - 'fields' => array_merge( - [ - 'ar_id', - 'ar_page_id', - 'ar_rev_id', - 'ar_text_id', - 'ar_timestamp', - 'ar_user_text', - 'ar_user', - 'ar_actor' => 'NULL', - 'ar_minor_edit', - 'ar_deleted', - 'ar_len', - 'ar_parent_id', - 'ar_sha1', - ], - $this->getContentHandlerQueryFields( 'ar' ), - [ - 'ar_comment_old' => 'ar_comment', - 'ar_comment_id' => 'ar_comment_id', - ] - ), - ]; - yield 'no mode, no comment, no actor' => [ - [ - 'wgContentHandlerUseDB' => false, - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ], - 'fields' => array_merge( - [ - 'ar_id', - 'ar_page_id', - 'ar_rev_id', - 'ar_text_id', - 'ar_timestamp', - 'ar_user_text', - 'ar_user', - 'ar_actor' => 'NULL', - 'ar_minor_edit', - 'ar_deleted', - 'ar_len', - 'ar_parent_id', - 'ar_sha1', - ], - $this->getOldCommentQueryFields( 'ar' ) - ), - ]; - } - - /** - * @dataProvider provideSelectFields - * @covers Revision::selectFields - */ - public function testRevisionSelectFields( $migrationStageSettings, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $this->hideDeprecated( 'Revision::selectFields' ); - $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectFields() ); - } - - /** - * @dataProvider provideSelectArchiveFields - * @covers Revision::selectArchiveFields - */ - public function testRevisionSelectArchiveFields( $migrationStageSettings, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $this->hideDeprecated( 'Revision::selectArchiveFields' ); - $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectArchiveFields() ); - } - - /** - * @covers Revision::userJoinCond - */ - public function testRevisionUserJoinCond() { - $this->hideDeprecated( 'Revision::userJoinCond' ); - $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); - $this->overrideMwServices(); - $this->assertEquals( - [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], - Revision::userJoinCond() - ); - } - - /** - * @covers Revision::pageJoinCond - */ - public function testRevisionPageJoinCond() { - $this->hideDeprecated( 'Revision::pageJoinCond' ); - $this->assertEquals( - [ 'INNER JOIN', [ 'page_id = rev_page' ] ], - Revision::pageJoinCond() - ); - } - - /** - * @covers Revision::selectTextFields - */ - public function testRevisionSelectTextFields() { - $this->hideDeprecated( 'Revision::selectTextFields' ); - $this->assertEquals( - $this->getTextQueryFields(), - Revision::selectTextFields() - ); - } - - /** - * @covers Revision::selectPageFields - */ - public function testRevisionSelectPageFields() { - $this->hideDeprecated( 'Revision::selectPageFields' ); - $this->assertEquals( - $this->getPageQueryFields(), - Revision::selectPageFields() - ); - } - - /** - * @covers Revision::selectUserFields - */ - public function testRevisionSelectUserFields() { - $this->hideDeprecated( 'Revision::selectUserFields' ); - $this->assertEquals( - $this->getUserQueryFields(), - Revision::selectUserFields() - ); - } - - /** - * @covers Revision::getArchiveQueryInfo - * @dataProvider provideArchiveQueryInfo - */ - public function testRevisionGetArchiveQueryInfo( $migrationStageSettings, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $queryInfo = Revision::getArchiveQueryInfo(); - $this->assertQueryInfoEquals( $expected, $queryInfo ); - } - - /** - * @covers Revision::getQueryInfo - * @dataProvider provideQueryInfo - */ - public function testRevisionGetQueryInfo( $migrationStageSettings, $options, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $queryInfo = Revision::getQueryInfo( $options ); - $this->assertQueryInfoEquals( $expected, $queryInfo ); - } - - /** - * @dataProvider provideQueryInfo - * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo - */ - public function testRevisionStoreGetQueryInfo( $migrationStageSettings, $options, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $queryInfo = $store->getQueryInfo( $options ); - $this->assertQueryInfoEquals( $expected, $queryInfo ); - } - - /** - * @dataProvider provideSlotsQueryInfo - * @covers \MediaWiki\Storage\RevisionStore::getSlotsQueryInfo - */ - public function testRevisionStoreGetSlotsQueryInfo( - $migrationStageSettings, - $options, - $expected - ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $queryInfo = $store->getSlotsQueryInfo( $options ); - $this->assertQueryInfoEquals( $expected, $queryInfo ); - } - - /** - * @dataProvider provideArchiveQueryInfo - * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo - */ - public function testRevisionStoreGetArchiveQueryInfo( $migrationStageSettings, $expected ) { - $this->setMwGlobals( $migrationStageSettings ); - $this->overrideMwServices(); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $queryInfo = $store->getArchiveQueryInfo(); - $this->assertQueryInfoEquals( $expected, $queryInfo ); - } - - private function assertQueryInfoEquals( $expected, $queryInfo ) { - $this->assertArrayEqualsIgnoringIntKeyOrder( - $expected['tables'], - $queryInfo['tables'], - 'tables' - ); - $this->assertArrayEqualsIgnoringIntKeyOrder( - $expected['fields'], - $queryInfo['fields'], - 'fields' - ); - $this->assertArrayEqualsIgnoringIntKeyOrder( - $expected['joins'], - $queryInfo['joins'], - 'joins' - ); - } - - /** - * Assert that the two arrays passed are equal, ignoring the order of the values that integer - * keys. - * - * Note: Failures of this assertion can be slightly confusing as the arrays are actually - * split into a string key array and an int key array before assertions occur. - * - * @param array $expected - * @param array $actual - */ - private function assertArrayEqualsIgnoringIntKeyOrder( - array $expected, - array $actual, - $message = null - ) { - $this->objectAssociativeSort( $expected ); - $this->objectAssociativeSort( $actual ); - - // Separate the int key values from the string key values so that assertion failures are - // easier to understand. - $expectedIntKeyValues = []; - $actualIntKeyValues = []; - - // Remove all int keys and re add them at the end after sorting by value - // This will result in all int keys being in the same order with same ints at the end of - // the array - foreach ( $expected as $key => $value ) { - if ( is_int( $key ) ) { - unset( $expected[$key] ); - $expectedIntKeyValues[] = $value; - } - } - foreach ( $actual as $key => $value ) { - if ( is_int( $key ) ) { - unset( $actual[$key] ); - $actualIntKeyValues[] = $value; - } - } - - $this->objectAssociativeSort( $expected ); - $this->objectAssociativeSort( $actual ); - - $this->objectAssociativeSort( $expectedIntKeyValues ); - $this->objectAssociativeSort( $actualIntKeyValues ); - - $this->assertEquals( $expected, $actual, $message ); - $this->assertEquals( $expectedIntKeyValues, $actualIntKeyValues, $message ); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionRecordTests.php b/tests/phpunit/includes/Storage/RevisionRecordTests.php deleted file mode 100644 index 901b80022c..0000000000 --- a/tests/phpunit/includes/Storage/RevisionRecordTests.php +++ /dev/null @@ -1,528 +0,0 @@ - [ - RevisionRecord::SUPPRESSED_ALL, - [ 'oversight' ], - true, - false - ]; - - yield 'field accessible for oversighter' => [ - RevisionRecord::DELETED_RESTRICTED | $field, - [ 'oversight' ], - true, - false - ]; - - yield 'field not accessible for sysops (ALL)' => [ - RevisionRecord::SUPPRESSED_ALL, - [ 'sysop' ], - false, - false - ]; - - yield 'field not accessible for sysops' => [ - RevisionRecord::DELETED_RESTRICTED | $field, - [ 'sysop' ], - false, - false - ]; - - yield 'field accessible for sysops' => [ - $field, - [ 'sysop' ], - true, - false - ]; - - yield 'field suppressed for logged in users' => [ - $field, - [ 'user' ], - false, - false - ]; - - yield 'unrelated field suppressed' => [ - $field === RevisionRecord::DELETED_COMMENT - ? RevisionRecord::DELETED_USER - : RevisionRecord::DELETED_COMMENT, - [ 'user' ], - true, - true - ]; - - yield 'nothing suppressed' => [ - 0, - [ 'user' ], - true, - true - ]; - } - - public function testSerialization_fails() { - $this->setExpectedException( LogicException::class ); - $rev = $this->newRevision(); - serialize( $rev ); - } - - public function provideGetComment_audience() { - return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT ); - } - - private function forceStandardPermissions() { - $this->setMwGlobals( - 'wgGroupPermissions', - [ - 'user' => [ - 'viewsuppressed' => false, - 'suppressrevision' => false, - 'deletedtext' => false, - 'deletedhistory' => false, - ], - 'sysop' => [ - 'viewsuppressed' => false, - 'suppressrevision' => false, - 'deletedtext' => true, - 'deletedhistory' => true, - ], - 'oversight' => [ - 'deletedtext' => true, - 'deletedhistory' => true, - 'viewsuppressed' => true, - 'suppressrevision' => true, - ], - ] - ); - } - - /** - * @dataProvider provideGetComment_audience - */ - public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) { - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $groups )->getUser(); - $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); - - $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' ); - - $this->assertSame( - $publicCan, - $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null, - 'public can' - ); - $this->assertSame( - $userCan, - $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null, - 'user can' - ); - } - - public function provideGetUser_audience() { - return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER ); - } - - /** - * @dataProvider provideGetUser_audience - */ - public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) { - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $groups )->getUser(); - $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); - - $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' ); - - $this->assertSame( - $publicCan, - $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null, - 'public can' - ); - $this->assertSame( - $userCan, - $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null, - 'user can' - ); - } - - public function provideGetSlot_audience() { - return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT ); - } - - /** - * @dataProvider provideGetSlot_audience - */ - public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) { - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $groups )->getUser(); - $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); - - // NOTE: slot meta-data is never suppressed, just the content is! - $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ), 'hasSlot is never suppressed' ); - $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw meta' ); - $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ), - 'public meta' ); - - $this->assertNotNull( - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ), - 'user can' - ); - - try { - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent(); - $exception = null; - } catch ( SuppressedDataException $ex ) { - $exception = $ex; - } - - $this->assertSame( - $publicCan, - $exception === null, - 'public can' - ); - - try { - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent(); - $exception = null; - } catch ( SuppressedDataException $ex ) { - $exception = $ex; - } - - $this->assertSame( - $userCan, - $exception === null, - 'user can' - ); - } - - /** - * @dataProvider provideGetSlot_audience - */ - public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) { - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $groups )->getUser(); - $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); - - $this->assertNotNull( $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' ); - - $this->assertSame( - $publicCan, - $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) !== null, - 'public can' - ); - $this->assertSame( - $userCan, - $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ) !== null, - 'user can' - ); - } - - public function testGetSlot() { - $rev = $this->newRevision(); - - $slot = $rev->getSlot( SlotRecord::MAIN ); - $this->assertNotNull( $slot, 'getSlot()' ); - $this->assertSame( 'main', $slot->getRole(), 'getRole()' ); - } - - public function testHasSlot() { - $rev = $this->newRevision(); - - $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ) ); - $this->assertFalse( $rev->hasSlot( 'xyz' ) ); - } - - public function testGetContent() { - $rev = $this->newRevision(); - - $content = $rev->getSlot( SlotRecord::MAIN ); - $this->assertNotNull( $content, 'getContent()' ); - $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' ); - } - - public function provideUserCanBitfield() { - yield [ 0, 0, [], null, true ]; - // Bitfields match, user has no permissions - yield [ - RevisionRecord::DELETED_TEXT, - RevisionRecord::DELETED_TEXT, - [], - null, - false - ]; - yield [ - RevisionRecord::DELETED_COMMENT, - RevisionRecord::DELETED_COMMENT, - [], - null, - false, - ]; - yield [ - RevisionRecord::DELETED_USER, - RevisionRecord::DELETED_USER, - [], - null, - false - ]; - yield [ - RevisionRecord::DELETED_RESTRICTED, - RevisionRecord::DELETED_RESTRICTED, - [], - null, - false, - ]; - // Bitfields match, user (admin) does have permissions - yield [ - RevisionRecord::DELETED_TEXT, - RevisionRecord::DELETED_TEXT, - [ 'sysop' ], - null, - true, - ]; - yield [ - RevisionRecord::DELETED_COMMENT, - RevisionRecord::DELETED_COMMENT, - [ 'sysop' ], - null, - true, - ]; - yield [ - RevisionRecord::DELETED_USER, - RevisionRecord::DELETED_USER, - [ 'sysop' ], - null, - true, - ]; - // Bitfields match, user (admin) does not have permissions - yield [ - RevisionRecord::DELETED_RESTRICTED, - RevisionRecord::DELETED_RESTRICTED, - [ 'sysop' ], - null, - false, - ]; - // Bitfields match, user (oversight) does have permissions - yield [ - RevisionRecord::DELETED_RESTRICTED, - RevisionRecord::DELETED_RESTRICTED, - [ 'oversight' ], - null, - true, - ]; - // Check permissions using the title - yield [ - RevisionRecord::DELETED_TEXT, - RevisionRecord::DELETED_TEXT, - [ 'sysop' ], - __METHOD__, - true, - ]; - yield [ - RevisionRecord::DELETED_TEXT, - RevisionRecord::DELETED_TEXT, - [], - __METHOD__, - false, - ]; - } - - /** - * @dataProvider provideUserCanBitfield - * @covers \MediaWiki\Storage\RevisionRecord::userCanBitfield - */ - public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) { - if ( is_string( $title ) ) { - // NOTE: Data providers cannot instantiate Title objects! See T202641. - $title = Title::newFromText( $title ); - } - - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $userGroups )->getUser(); - - $this->assertSame( - $expected, - RevisionRecord::userCanBitfield( $bitField, $field, $user, $title ) - ); - } - - public function provideHasSameContent() { - // Create some slots with content - $mainA = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'A' ) ); - $mainB = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'B' ) ); - $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) ); - $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) ); - - $initialRecordSpec = [ [ $mainA ], 12 ]; - - return [ - 'same record object' => [ - true, - $initialRecordSpec, - $initialRecordSpec, - ], - 'same record content, different object' => [ - true, - [ [ $mainA ], 12 ], - [ [ $mainA ], 13 ], - ], - 'same record content, aux slot, different object' => [ - true, - [ [ $auxA ], 12 ], - [ [ $auxB ], 13 ], - ], - 'different content' => [ - false, - [ [ $mainA ], 12 ], - [ [ $mainB ], 13 ], - ], - 'different content and number of slots' => [ - false, - [ [ $mainA ], 12 ], - [ [ $mainA, $mainB ], 13 ], - ], - ]; - } - - /** - * @note Do not call directly from a data provider! Data providers cannot instantiate - * Title objects! See T202641. - * - * @param SlotRecord[] $slots - * @param int $revId - * @return RevisionStoreRecord - */ - private function makeHasSameContentTestRecord( array $slots, $revId ) { - $title = Title::newFromText( 'provideHasSameContent' ); - $title->resetArticleID( 19 ); - $slots = new RevisionSlots( $slots ); - - return new RevisionStoreRecord( - $title, - new UserIdentityValue( 11, __METHOD__, 0 ), - CommentStoreComment::newUnsavedComment( __METHOD__ ), - (object)[ - 'rev_id' => strval( $revId ), - 'rev_page' => strval( $title->getArticleID() ), - 'rev_timestamp' => '20200101000000', - 'rev_deleted' => 0, - 'rev_minor_edit' => 0, - 'rev_parent_id' => '5', - 'rev_len' => $slots->computeSize(), - 'rev_sha1' => $slots->computeSha1(), - 'page_latest' => '18', - ], - $slots - ); - } - - /** - * @dataProvider provideHasSameContent - * @covers \MediaWiki\Storage\RevisionRecord::hasSameContent - * @group Database - */ - public function testHasSameContent( - $expected, - $recordSpec1, - $recordSpec2 - ) { - $record1 = $this->makeHasSameContentTestRecord( ...$recordSpec1 ); - $record2 = $this->makeHasSameContentTestRecord( ...$recordSpec2 ); - - $this->assertSame( - $expected, - $record1->hasSameContent( $record2 ) - ); - } - - public function provideIsDeleted() { - yield 'no deletion' => [ - 0, - [ - RevisionRecord::DELETED_TEXT => false, - RevisionRecord::DELETED_COMMENT => false, - RevisionRecord::DELETED_USER => false, - RevisionRecord::DELETED_RESTRICTED => false, - ] - ]; - yield 'text deleted' => [ - RevisionRecord::DELETED_TEXT, - [ - RevisionRecord::DELETED_TEXT => true, - RevisionRecord::DELETED_COMMENT => false, - RevisionRecord::DELETED_USER => false, - RevisionRecord::DELETED_RESTRICTED => false, - ] - ]; - yield 'text and comment deleted' => [ - RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT, - [ - RevisionRecord::DELETED_TEXT => true, - RevisionRecord::DELETED_COMMENT => true, - RevisionRecord::DELETED_USER => false, - RevisionRecord::DELETED_RESTRICTED => false, - ] - ]; - yield 'all 4 deleted' => [ - RevisionRecord::DELETED_TEXT + - RevisionRecord::DELETED_COMMENT + - RevisionRecord::DELETED_RESTRICTED + - RevisionRecord::DELETED_USER, - [ - RevisionRecord::DELETED_TEXT => true, - RevisionRecord::DELETED_COMMENT => true, - RevisionRecord::DELETED_USER => true, - RevisionRecord::DELETED_RESTRICTED => true, - ] - ]; - } - - /** - * @dataProvider provideIsDeleted - * @covers \MediaWiki\Storage\RevisionRecord::isDeleted - */ - public function testIsDeleted( $revDeleted, $assertionMap ) { - $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] ); - foreach ( $assertionMap as $deletionLevel => $expected ) { - $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) ); - } - } - - public function testIsReadyForInsertion() { - $rev = $this->newRevision(); - $this->assertTrue( $rev->isReadyForInsertion() ); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionSlotsTest.php b/tests/phpunit/includes/Storage/RevisionSlotsTest.php deleted file mode 100644 index 409e002cc1..0000000000 --- a/tests/phpunit/includes/Storage/RevisionSlotsTest.php +++ /dev/null @@ -1,257 +0,0 @@ - [ - 'foo' - ]; - yield 'array of the wrong thing' => [ - [ 1, 2, 3 ] - ]; - } - - /** - * @dataProvider provideConstructorFailue - * @param $slots - * - * @covers \MediaWiki\Storage\RevisionSlots::__construct - * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal - */ - public function testConstructorFailue( $slots ) { - $this->setExpectedException( InvalidArgumentException::class ); - - new RevisionSlots( $slots ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getSlot - */ - public function testGetSlot() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); - - $this->assertSame( $mainSlot, $slots->getSlot( SlotRecord::MAIN ) ); - $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) ); - $this->setExpectedException( RevisionAccessException::class ); - $slots->getSlot( 'nothere' ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::hasSlot - */ - public function testHasSlot() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); - - $this->assertTrue( $slots->hasSlot( SlotRecord::MAIN ) ); - $this->assertTrue( $slots->hasSlot( 'aux' ) ); - $this->assertFalse( $slots->hasSlot( 'AUX' ) ); - $this->assertFalse( $slots->hasSlot( 'xyz' ) ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getContent - */ - public function testGetContent() { - $mainContent = new WikitextContent( 'A' ); - $auxContent = new WikitextContent( 'B' ); - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, $mainContent ); - $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent ); - $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); - - $this->assertSame( $mainContent, $slots->getContent( SlotRecord::MAIN ) ); - $this->assertSame( $auxContent, $slots->getContent( 'aux' ) ); - $this->setExpectedException( RevisionAccessException::class ); - $slots->getContent( 'nothere' ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles - */ - public function testGetSlotRoles_someSlots() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); - - $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles - */ - public function testGetSlotRoles_noSlots() { - $slots = $this->newRevisionSlots( [] ); - - $this->assertSame( [], $slots->getSlotRoles() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getSlots - */ - public function testGetSlots() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slotsArray = [ $mainSlot, $auxSlot ]; - $slots = $this->newRevisionSlots( $slotsArray ); - - $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getInheritedSlots - */ - public function testGetInheritedSlots() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newInherited( - SlotRecord::newSaved( - 7, 7, 'foo', - SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ) - ) - ); - $slotsArray = [ $mainSlot, $auxSlot ]; - $slots = $this->newRevisionSlots( $slotsArray ); - - $this->assertEquals( [ 'aux' => $auxSlot ], $slots->getInheritedSlots() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionSlots::getOriginalSlots - */ - public function testGetOriginalSlots() { - $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $auxSlot = SlotRecord::newInherited( - SlotRecord::newSaved( - 7, 7, 'foo', - SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ) - ) - ); - $slotsArray = [ $mainSlot, $auxSlot ]; - $slots = $this->newRevisionSlots( $slotsArray ); - - $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() ); - } - - public function provideComputeSize() { - yield [ 1, [ 'A' ] ]; - yield [ 2, [ 'AA' ] ]; - yield [ 4, [ 'AA', 'X', 'H' ] ]; - } - - /** - * @dataProvider provideComputeSize - * @covers \MediaWiki\Storage\RevisionSlots::computeSize - */ - public function testComputeSize( $expected, $contentStrings ) { - $slotsArray = []; - foreach ( $contentStrings as $key => $contentString ) { - $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); - } - $slots = $this->newRevisionSlots( $slotsArray ); - - $this->assertSame( $expected, $slots->computeSize() ); - } - - public function provideComputeSha1() { - yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ]; - yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ]; - yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ]; - } - - /** - * @dataProvider provideComputeSha1 - * @covers \MediaWiki\Storage\RevisionSlots::computeSha1 - * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings - * are returned and different Slots objects return different strings? - */ - public function testComputeSha1( $expected, $contentStrings ) { - $slotsArray = []; - foreach ( $contentStrings as $key => $contentString ) { - $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); - } - $slots = $this->newRevisionSlots( $slotsArray ); - - $this->assertSame( $expected, $slots->computeSha1() ); - } - - public function provideHasSameContent() { - $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) ); - $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) ); - $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) ); - $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ ); - $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) ); - - $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); - $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); - $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] ); - $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] ); - $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] ); - - yield 'same instance' => [ $a, $a, true ]; - yield 'same slots' => [ $a, $a2, true ]; - yield 'same content' => [ $a, $a3, true ]; - - yield 'different roles' => [ $a, $b, false ]; - yield 'different content' => [ $a, $c, false ]; - } - - /** - * @dataProvider provideHasSameContent - * @covers \MediaWiki\Storage\RevisionSlots::hasSameContent - */ - public function testHasSameContent( RevisionSlots $a, RevisionSlots $b, $same ) { - $this->assertSame( $same, $a->hasSameContent( $b ) ); - $this->assertSame( $same, $b->hasSameContent( $a ) ); - } - - public function provideGetRolesWithDifferentContent() { - $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) ); - $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) ); - $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) ); - $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ ); - $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) ); - - $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); - $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] ); - $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] ); - $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] ); - $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] ); - - yield 'same instance' => [ $a, $a, [] ]; - yield 'same slots' => [ $a, $a2, [] ]; - yield 'same content' => [ $a, $a3, [] ]; - - yield 'different roles' => [ $a, $b, [ 'x', 'y' ] ]; - yield 'different content' => [ $a, $c, [ 'z' ] ]; - } - - /** - * @dataProvider provideGetRolesWithDifferentContent - * @covers \MediaWiki\Storage\RevisionSlots::getRolesWithDifferentContent - */ - public function testGetRolesWithDifferentContent( RevisionSlots $a, RevisionSlots $b, $roles ) { - $this->assertArrayEquals( $roles, $a->getRolesWithDifferentContent( $b ) ); - $this->assertArrayEquals( $roles, $b->getRolesWithDifferentContent( $a ) ); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionSlotsUpdateTest.php b/tests/phpunit/includes/Storage/RevisionSlotsUpdateTest.php index 75a4718ac2..442f4d2d33 100644 --- a/tests/phpunit/includes/Storage/RevisionSlotsUpdateTest.php +++ b/tests/phpunit/includes/Storage/RevisionSlotsUpdateTest.php @@ -3,11 +3,11 @@ namespace MediaWiki\Tests\Storage; use Content; -use MediaWiki\Storage\MutableRevisionSlots; -use MediaWiki\Storage\RevisionSlots; +use MediaWiki\Revision\MutableRevisionSlots; +use MediaWiki\Revision\RevisionSlots; +use MediaWiki\Revision\RevisionAccessException; +use MediaWiki\Revision\SlotRecord; use MediaWiki\Storage\RevisionSlotsUpdate; -use MediaWiki\Storage\RevisionAccessException; -use MediaWiki\Storage\SlotRecord; use MediaWikiTestCase; use WikitextContent; diff --git a/tests/phpunit/includes/Storage/RevisionStoreDbTestBase.php b/tests/phpunit/includes/Storage/RevisionStoreDbTestBase.php deleted file mode 100644 index 04b6aa82b2..0000000000 --- a/tests/phpunit/includes/Storage/RevisionStoreDbTestBase.php +++ /dev/null @@ -1,1662 +0,0 @@ -tablesUsed[] = 'archive'; - $this->tablesUsed[] = 'page'; - $this->tablesUsed[] = 'revision'; - $this->tablesUsed[] = 'comment'; - - $this->tablesUsed += $this->getMcrTablesToReset(); - - $this->setMwGlobals( [ - 'wgMultiContentRevisionSchemaMigrationStage' => $this->getMcrMigrationStage(), - 'wgContentHandlerUseDB' => $this->getContentHandlerUseDB(), - 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD, - 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD, - ] ); - - $this->overrideMwServices(); - } - - protected function addCoreDBData() { - // Blank out. This would fail with a modified schema, and we don't need it. - } - - /** - * @return Title - */ - protected function getTestPageTitle() { - if ( $this->testPageTitle ) { - return $this->testPageTitle; - } - - $this->testPageTitle = Title::newFromText( 'UTPage-' . __CLASS__ ); - return $this->testPageTitle; - } - /** - * @return WikiPage - */ - protected function getTestPage() { - if ( $this->testPage ) { - return $this->testPage; - } - - $title = $this->getTestPageTitle(); - $this->testPage = WikiPage::factory( $title ); - - if ( !$this->testPage->exists() ) { - // Make sure we don't write to the live db. - $this->ensureMockDatabaseConnection( wfGetDB( DB_MASTER ) ); - - $user = static::getTestSysop()->getUser(); - - $this->testPage->doEditContent( - new WikitextContent( 'UTContent-' . __CLASS__ ), - 'UTPageSummary-' . __CLASS__, - EDIT_NEW | EDIT_SUPPRESS_RC, - false, - $user - ); - } - - return $this->testPage; - } - - /** - * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject - */ - private function getLoadBalancerMock( array $server ) { - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->setMethods( [ 'reallyOpenConnection' ] ) - ->setConstructorArgs( [ [ 'servers' => [ $server ] ] ] ) - ->getMock(); - - $lb->method( 'reallyOpenConnection' )->willReturnCallback( - function ( array $server, $dbNameOverride ) { - return $this->getDatabaseMock( $server ); - } - ); - - return $lb; - } - - /** - * @return Database|PHPUnit_Framework_MockObject_MockObject - */ - private function getDatabaseMock( array $params ) { - $db = $this->getMockBuilder( DatabaseSqlite::class ) - ->setMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] ) - ->setConstructorArgs( [ $params ] ) - ->getMock(); - - $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) ); - $db->method( 'isOpen' )->willReturn( true ); - - return $db; - } - - public function provideDomainCheck() { - yield [ false, 'test', '' ]; - yield [ 'test', 'test', '' ]; - - yield [ false, 'test', 'foo_' ]; - yield [ 'test-foo_', 'test', 'foo_' ]; - - yield [ false, 'dash-test', '' ]; - yield [ 'dash-test', 'dash-test', '' ]; - - yield [ false, 'underscore_test', 'foo_' ]; - yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ]; - } - - /** - * @dataProvider provideDomainCheck - * @covers \MediaWiki\Storage\RevisionStore::checkDatabaseWikiId - */ - public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) { - $this->setMwGlobals( - [ - 'wgDBname' => $dbName, - 'wgDBprefix' => $dbPrefix, - ] - ); - - $loadBalancer = $this->getLoadBalancerMock( - [ - 'host' => '*dummy*', - 'dbDirectory' => '*dummy*', - 'user' => 'test', - 'password' => 'test', - 'flags' => 0, - 'variables' => [], - 'schema' => '', - 'cliMode' => true, - 'agent' => '', - 'load' => 100, - 'profiler' => null, - 'trxProfiler' => new TransactionProfiler(), - 'connLogger' => new \Psr\Log\NullLogger(), - 'queryLogger' => new \Psr\Log\NullLogger(), - 'errorLogger' => function () { - }, - 'deprecationLogger' => function () { - }, - 'type' => 'test', - 'dbname' => $dbName, - 'tablePrefix' => $dbPrefix, - ] - ); - $db = $loadBalancer->getConnection( DB_REPLICA ); - - /** @var SqlBlobStore $blobStore */ - $blobStore = $this->getMockBuilder( SqlBlobStore::class ) - ->disableOriginalConstructor() - ->getMock(); - - $store = new RevisionStore( - $loadBalancer, - $blobStore, - new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ), - MediaWikiServices::getInstance()->getCommentStore(), - MediaWikiServices::getInstance()->getContentModelStore(), - MediaWikiServices::getInstance()->getSlotRoleStore(), - $this->getMcrMigrationStage(), - MediaWikiServices::getInstance()->getActorMigration(), - $wikiId - ); - - $count = $store->countRevisionsByPageId( $db, 0 ); - - // Dummy check to make PhpUnit happy. We are really only interested in - // countRevisionsByPageId not failing due to the DB domain check. - $this->assertSame( 0, $count ); - } - - protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) { - $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() ); - $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() ); - $this->assertEquals( $l1->getFragment(), $l2->getFragment() ); - $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() ); - } - - protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) { - $this->assertEquals( - $r1->getPageAsLinkTarget()->getNamespace(), - $r2->getPageAsLinkTarget()->getNamespace() - ); - - $this->assertEquals( - $r1->getPageAsLinkTarget()->getText(), - $r2->getPageAsLinkTarget()->getText() - ); - - if ( $r1->getParentId() ) { - $this->assertEquals( $r1->getParentId(), $r2->getParentId() ); - } - - $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() ); - $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() ); - $this->assertEquals( $r1->getComment(), $r2->getComment() ); - $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() ); - $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() ); - $this->assertEquals( $r1->getSha1(), $r2->getSha1() ); - $this->assertEquals( $r1->getSize(), $r2->getSize() ); - $this->assertEquals( $r1->getPageId(), $r2->getPageId() ); - $this->assertArrayEquals( $r1->getSlotRoles(), $r2->getSlotRoles() ); - $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() ); - $this->assertEquals( $r1->isMinor(), $r2->isMinor() ); - foreach ( $r1->getSlotRoles() as $role ) { - $this->assertSlotRecordsEqual( $r1->getSlot( $role ), $r2->getSlot( $role ) ); - $this->assertTrue( $r1->getContent( $role )->equals( $r2->getContent( $role ) ) ); - } - foreach ( [ - RevisionRecord::DELETED_TEXT, - RevisionRecord::DELETED_COMMENT, - RevisionRecord::DELETED_USER, - RevisionRecord::DELETED_RESTRICTED, - ] as $field ) { - $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) ); - } - } - - protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) { - $this->assertSame( $s1->getRole(), $s2->getRole() ); - $this->assertSame( $s1->getModel(), $s2->getModel() ); - $this->assertSame( $s1->getFormat(), $s2->getFormat() ); - $this->assertSame( $s1->getSha1(), $s2->getSha1() ); - $this->assertSame( $s1->getSize(), $s2->getSize() ); - $this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) ); - - $s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null; - $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null; - } - - protected function assertRevisionCompleteness( RevisionRecord $r ) { - $this->assertTrue( $r->hasSlot( SlotRecord::MAIN ) ); - $this->assertInstanceOf( SlotRecord::class, $r->getSlot( SlotRecord::MAIN ) ); - $this->assertInstanceOf( Content::class, $r->getContent( SlotRecord::MAIN ) ); - - foreach ( $r->getSlotRoles() as $role ) { - $this->assertSlotCompleteness( $r, $r->getSlot( $role ) ); - } - } - - protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) { - $this->assertTrue( $slot->hasAddress() ); - $this->assertSame( $r->getId(), $slot->getRevision() ); - - $this->assertInstanceOf( Content::class, $slot->getContent() ); - } - - /** - * @param mixed[] $details - * - * @return RevisionRecord - */ - private function getRevisionRecordFromDetailsArray( $details = [] ) { - // Convert some values that can't be provided by dataProviders - if ( isset( $details['user'] ) && $details['user'] === true ) { - $details['user'] = $this->getTestUser()->getUser(); - } - if ( isset( $details['page'] ) && $details['page'] === true ) { - $details['page'] = $this->getTestPage()->getId(); - } - if ( isset( $details['parent'] ) && $details['parent'] === true ) { - $details['parent'] = $this->getTestPage()->getLatest(); - } - - // Create the RevisionRecord with any available data - $rev = new MutableRevisionRecord( $this->getTestPageTitle() ); - isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null; - isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null; - isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null; - isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null; - isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null; - isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null; - isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null; - isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null; - isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null; - isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null; - isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null; - - if ( isset( $details['content'] ) ) { - foreach ( $details['content'] as $role => $content ) { - $rev->setContent( $role, $content ); - } - } - - return $rev; - } - - public function provideInsertRevisionOn_successes() { - yield 'Bare minimum revision insertion' => [ - [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'page' => true, - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - ]; - yield 'Detailed revision insertion' => [ - [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'parent' => true, - 'page' => true, - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - 'minor' => true, - 'visibility' => RevisionRecord::DELETED_RESTRICTED, - ], - ]; - } - - protected function getRandomCommentStoreComment() { - return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) ); - } - - /** - * @dataProvider provideInsertRevisionOn_successes - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn - * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn - */ - public function testInsertRevisionOn_successes( - array $revDetails = [] - ) { - $title = $this->getTestPageTitle(); - $rev = $this->getRevisionRecordFromDetailsArray( $revDetails ); - - $this->overrideMwServices(); - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) ); - - // is the new revision correct? - $this->assertRevisionCompleteness( $return ); - $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() ); - $this->assertRevisionRecordsEqual( $rev, $return ); - - // can we load it from the store? - $loaded = $store->getRevisionById( $return->getId() ); - $this->assertRevisionCompleteness( $loaded ); - $this->assertRevisionRecordsEqual( $return, $loaded ); - - // can we find it directly in the database? - $this->assertRevisionExistsInDatabase( $return ); - } - - protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) { - $row = $this->revisionToRow( new Revision( $rev ), [] ); - - // unset nulled fields - unset( $row->rev_content_model ); - unset( $row->rev_content_format ); - - // unset fake fields - unset( $row->rev_comment_text ); - unset( $row->rev_comment_data ); - unset( $row->rev_comment_cid ); - unset( $row->rev_comment_id ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $queryInfo = $store->getQueryInfo( [ 'user' ] ); - - $row = get_object_vars( $row ); - $this->assertSelect( - $queryInfo['tables'], - array_keys( $row ), - [ 'rev_id' => $rev->getId() ], - [ array_values( $row ) ], - [], - $queryInfo['joins'] - ); - } - - /** - * @param SlotRecord $a - * @param SlotRecord $b - */ - protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) { - // Assert that the same blob address has been used. - $this->assertSame( $a->getAddress(), $b->getAddress() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - */ - public function testInsertRevisionOn_blobAddressExists() { - $title = $this->getTestPageTitle(); - $revDetails = [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'parent' => true, - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ]; - - $this->overrideMwServices(); - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - // Insert the first revision - $revOne = $this->getRevisionRecordFromDetailsArray( $revDetails ); - $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) ); - $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() ); - $this->assertRevisionRecordsEqual( $revOne, $firstReturn ); - - // Insert a second revision inheriting the same blob address - $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( SlotRecord::MAIN ) ); - $revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails ); - $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) ); - $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() ); - $this->assertRevisionRecordsEqual( $revTwo, $secondReturn ); - - $firstMainSlot = $firstReturn->getSlot( SlotRecord::MAIN ); - $secondMainSlot = $secondReturn->getSlot( SlotRecord::MAIN ); - - $this->assertSameSlotContent( $firstMainSlot, $secondMainSlot ); - - // And that different revisions have been created. - $this->assertNotSame( $firstReturn->getId(), $secondReturn->getId() ); - - // Make sure the slot rows reference the correct revision - $this->assertSame( $firstReturn->getId(), $firstMainSlot->getRevision() ); - $this->assertSame( $secondReturn->getId(), $secondMainSlot->getRevision() ); - } - - public function provideInsertRevisionOn_failures() { - yield 'no slot' => [ - [ - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - new InvalidArgumentException( 'main slot must be provided' ) - ]; - yield 'no main slot' => [ - [ - 'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ), - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - 'user' => true, - ], - new InvalidArgumentException( 'main slot must be provided' ) - ]; - yield 'no timestamp' => [ - [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'comment' => $this->getRandomCommentStoreComment(), - 'user' => true, - ], - new IncompleteRevisionException( 'timestamp field must not be NULL!' ) - ]; - yield 'no comment' => [ - [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'timestamp' => '20171117010101', - 'user' => true, - ], - new IncompleteRevisionException( 'comment must not be NULL!' ) - ]; - yield 'no user' => [ - [ - 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ), - 'comment' => $this->getRandomCommentStoreComment(), - 'timestamp' => '20171117010101', - ], - new IncompleteRevisionException( 'user must not be NULL!' ) - ]; - } - - /** - * @dataProvider provideInsertRevisionOn_failures - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - */ - public function testInsertRevisionOn_failures( - array $revDetails = [], - Exception $exception - ) { - $rev = $this->getRevisionRecordFromDetailsArray( $revDetails ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $this->setExpectedException( - get_class( $exception ), - $exception->getMessage(), - $exception->getCode() - ); - $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) ); - } - - public function provideNewNullRevision() { - yield [ - Title::newFromText( 'UTPage_notAutoCreated' ), - [ 'content' => [ 'main' => new WikitextContent( 'Flubber1' ) ] ], - CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ), - true, - ]; - yield [ - Title::newFromText( 'UTPage_notAutoCreated' ), - [ 'content' => [ 'main' => new WikitextContent( 'Flubber2' ) ] ], - CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ), - false, - ]; - } - - /** - * @dataProvider provideNewNullRevision - * @covers \MediaWiki\Storage\RevisionStore::newNullRevision - * @covers \MediaWiki\Storage\RevisionStore::findSlotContentId - */ - public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) { - $this->overrideMwServices(); - - $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser(); - $page = WikiPage::factory( $title ); - - if ( !$page->exists() ) { - $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__, EDIT_NEW ); - } - - $revDetails['page'] = $page->getId(); - $revDetails['timestamp'] = wfTimestampNow(); - $revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' ); - $revDetails['user'] = $user; - - $baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails ); - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $dbw = wfGetDB( DB_MASTER ); - $baseRev = $store->insertRevisionOn( $baseRev, $dbw ); - $page->updateRevisionOn( $dbw, new Revision( $baseRev ), $page->getLatest() ); - - $record = $store->newNullRevision( - wfGetDB( DB_MASTER ), - $title, - $comment, - $minor, - $user - ); - - $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() ); - $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() ); - $this->assertEquals( $comment, $record->getComment() ); - $this->assertEquals( $minor, $record->isMinor() ); - $this->assertEquals( $user->getName(), $record->getUser()->getName() ); - $this->assertEquals( $baseRev->getId(), $record->getParentId() ); - - $this->assertArrayEquals( - $baseRev->getSlotRoles(), - $record->getSlotRoles() - ); - - foreach ( $baseRev->getSlotRoles() as $role ) { - $parentSlot = $baseRev->getSlot( $role ); - $slot = $record->getSlot( $role ); - - $this->assertTrue( $slot->isInherited(), 'isInherited' ); - $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' ); - $this->assertSameSlotContent( $parentSlot, $slot ); - } - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newNullRevision - */ - public function testNewNullRevision_nonExistingTitle() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $record = $store->newNullRevision( - wfGetDB( DB_MASTER ), - Title::newFromText( __METHOD__ . '.iDontExist!' ), - CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ), - false, - TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser() - ); - $this->assertNull( $record ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled - */ - public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() { - $page = $this->getTestPage(); - $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revisionRecord = $store->getRevisionById( $rev->getId() ); - $result = $store->getRcIdIfUnpatrolled( $revisionRecord ); - - $this->assertGreaterThan( 0, $result ); - $this->assertSame( - $store->getRecentChange( $revisionRecord )->getAttribute( 'rc_id' ), - $result - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled - */ - public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() { - // This assumes that sysops are auto patrolled - $sysop = $this->getTestSysop()->getUser(); - $page = $this->getTestPage(); - $status = $page->doEditContent( - new WikitextContent( __METHOD__ ), - __METHOD__, - 0, - false, - $sysop - ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revisionRecord = $store->getRevisionById( $rev->getId() ); - $result = $store->getRcIdIfUnpatrolled( $revisionRecord ); - - $this->assertSame( 0, $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRecentChange - */ - public function testGetRecentChange() { - $page = $this->getTestPage(); - $content = new WikitextContent( __METHOD__ ); - $status = $page->doEditContent( $content, __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revRecord = $store->getRevisionById( $rev->getId() ); - $recentChange = $store->getRecentChange( $revRecord ); - - $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) ); - $this->assertEquals( $rev->getRecentChange(), $recentChange ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRevisionById - */ - public function testGetRevisionById() { - $page = $this->getTestPage(); - $content = new WikitextContent( __METHOD__ ); - $status = $page->doEditContent( $content, __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revRecord = $store->getRevisionById( $rev->getId() ); - - $this->assertSame( $rev->getId(), $revRecord->getId() ); - $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); - $this->assertSame( __METHOD__, $revRecord->getComment()->text ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTitle - */ - public function testGetRevisionByTitle() { - $page = $this->getTestPage(); - $content = new WikitextContent( __METHOD__ ); - $status = $page->doEditContent( $content, __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revRecord = $store->getRevisionByTitle( $page->getTitle() ); - - $this->assertSame( $rev->getId(), $revRecord->getId() ); - $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); - $this->assertSame( __METHOD__, $revRecord->getComment()->text ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRevisionByPageId - */ - public function testGetRevisionByPageId() { - $page = $this->getTestPage(); - $content = new WikitextContent( __METHOD__ ); - $status = $page->doEditContent( $content, __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revRecord = $store->getRevisionByPageId( $page->getId() ); - - $this->assertSame( $rev->getId(), $revRecord->getId() ); - $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); - $this->assertSame( __METHOD__, $revRecord->getComment()->text ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTimestamp - */ - public function testGetRevisionByTimestamp() { - // Make sure there is 1 second between the last revision and the rev we create... - // Otherwise we might not get the correct revision and the test may fail... - // :( - $page = $this->getTestPage(); - sleep( 1 ); - $content = new WikitextContent( __METHOD__ ); - $status = $page->doEditContent( $content, __METHOD__ ); - /** @var Revision $rev */ - $rev = $status->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $revRecord = $store->getRevisionByTimestamp( - $page->getTitle(), - $rev->getTimestamp() - ); - - $this->assertSame( $rev->getId(), $revRecord->getId() ); - $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) ); - $this->assertSame( __METHOD__, $revRecord->getComment()->text ); - } - - protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) { - // XXX: the WikiPage object loads another RevisionRecord from the database. Not great. - $page = WikiPage::factory( $rev->getTitle() ); - - $fields = [ - 'rev_id' => (string)$rev->getId(), - 'rev_page' => (string)$rev->getPage(), - 'rev_timestamp' => $this->db->timestamp( $rev->getTimestamp() ), - 'rev_user_text' => (string)$rev->getUserText(), - 'rev_user' => (string)$rev->getUser(), - 'rev_minor_edit' => $rev->isMinor() ? '1' : '0', - 'rev_deleted' => (string)$rev->getVisibility(), - 'rev_len' => (string)$rev->getSize(), - 'rev_parent_id' => (string)$rev->getParentId(), - 'rev_sha1' => (string)$rev->getSha1(), - ]; - - if ( in_array( 'page', $options ) ) { - $fields += [ - 'page_namespace' => (string)$page->getTitle()->getNamespace(), - 'page_title' => $page->getTitle()->getDBkey(), - 'page_id' => (string)$page->getId(), - 'page_latest' => (string)$page->getLatest(), - 'page_is_redirect' => $page->isRedirect() ? '1' : '0', - 'page_len' => (string)$page->getContent()->getSize(), - ]; - } - - if ( in_array( 'user', $options ) ) { - $fields += [ - 'user_name' => (string)$rev->getUserText(), - ]; - } - - if ( in_array( 'comment', $options ) ) { - $fields += [ - 'rev_comment_text' => $rev->getComment(), - 'rev_comment_data' => null, - 'rev_comment_cid' => null, - ]; - } - - if ( $rev->getId() ) { - $fields += [ - 'rev_id' => (string)$rev->getId(), - ]; - } - - return (object)$fields; - } - - protected function assertRevisionRecordMatchesRevision( - Revision $rev, - RevisionRecord $record - ) { - $this->assertSame( $rev->getId(), $record->getId() ); - $this->assertSame( $rev->getPage(), $record->getPageId() ); - $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() ); - $this->assertSame( $rev->getUserText(), $record->getUser()->getName() ); - $this->assertSame( $rev->getUser(), $record->getUser()->getId() ); - $this->assertSame( $rev->isMinor(), $record->isMinor() ); - $this->assertSame( $rev->getVisibility(), $record->getVisibility() ); - $this->assertSame( $rev->getSize(), $record->getSize() ); - /** - * @note As of MW 1.31, the database schema allows the parent ID to be - * NULL to indicate that it is unknown. - */ - $expectedParent = $rev->getParentId(); - if ( $expectedParent === null ) { - $expectedParent = 0; - } - $this->assertSame( $expectedParent, $record->getParentId() ); - $this->assertSame( $rev->getSha1(), $record->getSha1() ); - $this->assertSame( $rev->getComment(), $record->getComment()->text ); - $this->assertSame( $rev->getContentFormat(), - $record->getContent( SlotRecord::MAIN )->getDefaultFormat() ); - $this->assertSame( $rev->getContentModel(), $record->getContent( SlotRecord::MAIN )->getModel() ); - $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() ); - - $revRec = $rev->getRevisionRecord(); - $revMain = $revRec->getSlot( SlotRecord::MAIN ); - $recMain = $record->getSlot( SlotRecord::MAIN ); - - $this->assertSame( $revMain->hasOrigin(), $recMain->hasOrigin(), 'hasOrigin' ); - $this->assertSame( $revMain->hasAddress(), $recMain->hasAddress(), 'hasAddress' ); - $this->assertSame( $revMain->hasContentId(), $recMain->hasContentId(), 'hasContentId' ); - - if ( $revMain->hasOrigin() ) { - $this->assertSame( $revMain->getOrigin(), $recMain->getOrigin(), 'getOrigin' ); - } - - if ( $revMain->hasAddress() ) { - $this->assertSame( $revMain->getAddress(), $recMain->getAddress(), 'getAddress' ); - } - - if ( $revMain->hasContentId() ) { - $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); - } - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo - */ - public function testNewRevisionFromRow_getQueryInfo() { - $page = $this->getTestPage(); - $text = __METHOD__ . 'a-ä'; - /** @var Revision $rev */ - $rev = $page->doEditContent( - new WikitextContent( $text ), - __METHOD__ . 'a' - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $info = $store->getQueryInfo(); - $row = $this->db->selectRow( - $info['tables'], - $info['fields'], - [ 'rev_id' => $rev->getId() ], - __METHOD__, - [], - $info['joins'] - ); - $record = $store->newRevisionFromRow( - $row, - [], - $page->getTitle() - ); - $this->assertRevisionRecordMatchesRevision( $rev, $record ); - $this->assertSame( $text, $rev->getContent()->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_anonEdit() { - $page = $this->getTestPage(); - $text = __METHOD__ . 'a-ä'; - /** @var Revision $rev */ - $rev = $page->doEditContent( - new WikitextContent( $text ), - __METHOD__ . 'a' - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $record = $store->newRevisionFromRow( - $this->revisionToRow( $rev ), - [], - $page->getTitle() - ); - $this->assertRevisionRecordMatchesRevision( $rev, $record ); - $this->assertSame( $text, $rev->getContent()->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_anonEdit_legacyEncoding() { - $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); - $this->overrideMwServices(); - $page = $this->getTestPage(); - $text = __METHOD__ . 'a-ä'; - /** @var Revision $rev */ - $rev = $page->doEditContent( - new WikitextContent( $text ), - __METHOD__ . 'a' - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $record = $store->newRevisionFromRow( - $this->revisionToRow( $rev ), - [], - $page->getTitle() - ); - $this->assertRevisionRecordMatchesRevision( $rev, $record ); - $this->assertSame( $text, $rev->getContent()->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_userEdit() { - $page = $this->getTestPage(); - $text = __METHOD__ . 'b-ä'; - /** @var Revision $rev */ - $rev = $page->doEditContent( - new WikitextContent( $text ), - __METHOD__ . 'b', - 0, - false, - $this->getTestUser()->getUser() - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $record = $store->newRevisionFromRow( - $this->revisionToRow( $rev ), - [], - $page->getTitle() - ); - $this->assertRevisionRecordMatchesRevision( $rev, $record ); - $this->assertSame( $text, $rev->getContent()->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow - * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo - */ - public function testNewRevisionFromArchiveRow_getArchiveQueryInfo() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $title = Title::newFromText( __METHOD__ ); - $text = __METHOD__ . '-bä'; - $page = WikiPage::factory( $title ); - /** @var Revision $orig */ - $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ ) - ->value['revision']; - $page->doDeleteArticle( __METHOD__ ); - - $db = wfGetDB( DB_MASTER ); - $arQuery = $store->getArchiveQueryInfo(); - $res = $db->select( - $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], - __METHOD__, [], $arQuery['joins'] - ); - $this->assertTrue( is_object( $res ), 'query failed' ); - - $row = $res->fetchObject(); - $res->free(); - $record = $store->newRevisionFromArchiveRow( $row ); - - $this->assertRevisionRecordMatchesRevision( $orig, $record ); - $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow - */ - public function testNewRevisionFromArchiveRow_legacyEncoding() { - $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); - $this->overrideMwServices(); - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $title = Title::newFromText( __METHOD__ ); - $text = __METHOD__ . '-bä'; - $page = WikiPage::factory( $title ); - /** @var Revision $orig */ - $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ ) - ->value['revision']; - $page->doDeleteArticle( __METHOD__ ); - - $db = wfGetDB( DB_MASTER ); - $arQuery = $store->getArchiveQueryInfo(); - $res = $db->select( - $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], - __METHOD__, [], $arQuery['joins'] - ); - $this->assertTrue( is_object( $res ), 'query failed' ); - - $row = $res->fetchObject(); - $res->free(); - $record = $store->newRevisionFromArchiveRow( $row ); - - $this->assertRevisionRecordMatchesRevision( $orig, $record ); - $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow - */ - public function testNewRevisionFromArchiveRow_no_user() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - $row = (object)[ - 'ar_id' => '1', - 'ar_page_id' => '2', - 'ar_namespace' => '0', - 'ar_title' => 'Something', - 'ar_rev_id' => '2', - 'ar_text_id' => '47', - 'ar_timestamp' => '20180528192356', - 'ar_minor_edit' => '0', - 'ar_deleted' => '0', - 'ar_len' => '78', - 'ar_parent_id' => '0', - 'ar_sha1' => 'deadbeef', - 'ar_comment_text' => 'whatever', - 'ar_comment_data' => null, - 'ar_comment_cid' => null, - 'ar_user' => '0', - 'ar_user_text' => '', // this is the important bit - 'ar_actor' => null, - 'ar_content_format' => null, - 'ar_content_model' => null, - ]; - - \Wikimedia\suppressWarnings(); - $record = $store->newRevisionFromArchiveRow( $row ); - \Wikimedia\suppressWarnings( true ); - - $this->assertInstanceOf( RevisionRecord::class, $record ); - $this->assertInstanceOf( UserIdentityValue::class, $record->getUser() ); - $this->assertSame( 'Unknown user', $record->getUser()->getName() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_no_user() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $title = Title::newFromText( __METHOD__ ); - - $row = (object)[ - 'rev_id' => '2', - 'rev_page' => '2', - 'page_namespace' => '0', - 'page_title' => $title->getText(), - 'rev_text_id' => '47', - 'rev_timestamp' => '20180528192356', - 'rev_minor_edit' => '0', - 'rev_deleted' => '0', - 'rev_len' => '78', - 'rev_parent_id' => '0', - 'rev_sha1' => 'deadbeef', - 'rev_comment_text' => 'whatever', - 'rev_comment_data' => null, - 'rev_comment_cid' => null, - 'rev_user' => '0', - 'rev_user_text' => '', // this is the important bit - 'rev_actor' => null, - 'rev_content_format' => null, - 'rev_content_model' => null, - ]; - - \Wikimedia\suppressWarnings(); - $record = $store->newRevisionFromRow( $row, 0, $title ); - \Wikimedia\suppressWarnings( true ); - - $this->assertNotNull( $record ); - $this->assertNotNull( $record->getUser() ); - $this->assertNotEmpty( $record->getUser()->getName() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn - */ - public function testInsertRevisionOn_archive() { - // This is a round trip test for deletion and undeletion of a - // revision row via the archive table. - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $title = Title::newFromText( __METHOD__ ); - - $page = WikiPage::factory( $title ); - /** @var Revision $origRev */ - $page->doEditContent( new WikitextContent( "First" ), __METHOD__ . '-first' ); - $origRev = $page->doEditContent( new WikitextContent( "Foo" ), __METHOD__ ) - ->value['revision']; - $orig = $origRev->getRevisionRecord(); - $page->doDeleteArticle( __METHOD__ ); - - // re-create page, so we can later load revisions for it - $page->doEditContent( new WikitextContent( 'Two' ), __METHOD__ ); - - $db = wfGetDB( DB_MASTER ); - $arQuery = $store->getArchiveQueryInfo(); - $row = $db->selectRow( - $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ], - __METHOD__, [], $arQuery['joins'] - ); - - $this->assertNotFalse( $row, 'query failed' ); - - $record = $store->newRevisionFromArchiveRow( - $row, - 0, - $title, - [ 'page_id' => $title->getArticleID() ] - ); - - $restored = $store->insertRevisionOn( $record, $db ); - - // is the new revision correct? - $this->assertRevisionCompleteness( $restored ); - $this->assertRevisionRecordsEqual( $record, $restored ); - - // does the new revision use the original slot? - $recMain = $record->getSlot( SlotRecord::MAIN ); - $restMain = $restored->getSlot( SlotRecord::MAIN ); - $this->assertSame( $recMain->getAddress(), $restMain->getAddress() ); - $this->assertSame( $recMain->getContentId(), $restMain->getContentId() ); - $this->assertSame( $recMain->getOrigin(), $restMain->getOrigin() ); - $this->assertSame( 'Foo', $restMain->getContent()->serialize() ); - - // can we load it from the store? - $loaded = $store->getRevisionById( $restored->getId() ); - $this->assertNotNull( $loaded ); - $this->assertRevisionCompleteness( $loaded ); - $this->assertRevisionRecordsEqual( $restored, $loaded ); - - // can we find it directly in the database? - $this->assertRevisionExistsInDatabase( $restored ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromId - */ - public function testLoadRevisionFromId() { - $title = Title::newFromText( __METHOD__ ); - $page = WikiPage::factory( $title ); - /** @var Revision $rev */ - $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() ); - $this->assertRevisionRecordMatchesRevision( $rev, $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromPageId - */ - public function testLoadRevisionFromPageId() { - $title = Title::newFromText( __METHOD__ ); - $page = WikiPage::factory( $title ); - /** @var Revision $rev */ - $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() ); - $this->assertRevisionRecordMatchesRevision( $rev, $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTitle - */ - public function testLoadRevisionFromTitle() { - $title = Title::newFromText( __METHOD__ ); - $page = WikiPage::factory( $title ); - /** @var Revision $rev */ - $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title ); - $this->assertRevisionRecordMatchesRevision( $rev, $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTimestamp - */ - public function testLoadRevisionFromTimestamp() { - $title = Title::newFromText( __METHOD__ ); - $page = WikiPage::factory( $title ); - /** @var Revision $revOne */ - $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - // Sleep to ensure different timestamps... )(evil) - sleep( 1 ); - /** @var Revision $revTwo */ - $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $this->assertNull( - $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' ) - ); - $this->assertSame( - $revOne->getId(), - $store->loadRevisionFromTimestamp( - wfGetDB( DB_MASTER ), - $title, - $revOne->getTimestamp() - )->getId() - ); - $this->assertSame( - $revTwo->getId(), - $store->loadRevisionFromTimestamp( - wfGetDB( DB_MASTER ), - $title, - $revTwo->getTimestamp() - )->getId() - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::listRevisionSizes - */ - public function testGetParentLengths() { - $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); - /** @var Revision $revOne */ - $revOne = $page->doEditContent( - new WikitextContent( __METHOD__ ), __METHOD__ - )->value['revision']; - /** @var Revision $revTwo */ - $revTwo = $page->doEditContent( - new WikitextContent( __METHOD__ . '2' ), __METHOD__ - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $this->assertSame( - [ - $revOne->getId() => strlen( __METHOD__ ), - ], - $store->listRevisionSizes( - wfGetDB( DB_MASTER ), - [ $revOne->getId() ] - ) - ); - $this->assertSame( - [ - $revOne->getId() => strlen( __METHOD__ ), - $revTwo->getId() => strlen( __METHOD__ ) + 1, - ], - $store->listRevisionSizes( - wfGetDB( DB_MASTER ), - [ $revOne->getId(), $revTwo->getId() ] - ) - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getPreviousRevision - */ - public function testGetPreviousRevision() { - $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); - /** @var Revision $revOne */ - $revOne = $page->doEditContent( - new WikitextContent( __METHOD__ ), __METHOD__ - )->value['revision']; - /** @var Revision $revTwo */ - $revTwo = $page->doEditContent( - new WikitextContent( __METHOD__ . '2' ), __METHOD__ - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $this->assertNull( - $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) ) - ); - $this->assertSame( - $revOne->getId(), - $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId() - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getNextRevision - */ - public function testGetNextRevision() { - $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); - /** @var Revision $revOne */ - $revOne = $page->doEditContent( - new WikitextContent( __METHOD__ ), __METHOD__ - )->value['revision']; - /** @var Revision $revTwo */ - $revTwo = $page->doEditContent( - new WikitextContent( __METHOD__ . '2' ), __METHOD__ - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $this->assertSame( - $revTwo->getId(), - $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId() - ); - $this->assertNull( - $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) ) - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId - */ - public function testGetTimestampFromId_found() { - $page = $this->getTestPage(); - /** @var Revision $rev */ - $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->getTimestampFromId( - $page->getTitle(), - $rev->getId() - ); - - $this->assertSame( $rev->getTimestamp(), $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId - */ - public function testGetTimestampFromId_notFound() { - $page = $this->getTestPage(); - /** @var Revision $rev */ - $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ) - ->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->getTimestampFromId( - $page->getTitle(), - $rev->getId() + 1 - ); - - $this->assertFalse( $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByPageId - */ - public function testCountRevisionsByPageId() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); - - $this->assertSame( - 0, - $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) - ); - $page->doEditContent( new WikitextContent( 'a' ), 'a' ); - $this->assertSame( - 1, - $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) - ); - $page->doEditContent( new WikitextContent( 'b' ), 'b' ); - $this->assertSame( - 2, - $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() ) - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByTitle - */ - public function testCountRevisionsByTitle() { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $page = WikiPage::factory( Title::newFromText( __METHOD__ ) ); - - $this->assertSame( - 0, - $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) - ); - $page->doEditContent( new WikitextContent( 'a' ), 'a' ); - $this->assertSame( - 1, - $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) - ); - $page->doEditContent( new WikitextContent( 'b' ), 'b' ); - $this->assertSame( - 2, - $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() ) - ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit - */ - public function testUserWasLastToEdit_false() { - $sysop = $this->getTestSysop()->getUser(); - $page = $this->getTestPage(); - $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->userWasLastToEdit( - wfGetDB( DB_MASTER ), - $page->getId(), - $sysop->getId(), - '20160101010101' - ); - $this->assertFalse( $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit - */ - public function testUserWasLastToEdit_true() { - $startTime = wfTimestampNow(); - $sysop = $this->getTestSysop()->getUser(); - $page = $this->getTestPage(); - $page->doEditContent( - new WikitextContent( __METHOD__ ), - __METHOD__, - 0, - false, - $sysop - ); - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $result = $store->userWasLastToEdit( - wfGetDB( DB_MASTER ), - $page->getId(), - $sysop->getId(), - $startTime - ); - $this->assertTrue( $result ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getKnownCurrentRevision - */ - public function testGetKnownCurrentRevision() { - $page = $this->getTestPage(); - /** @var Revision $rev */ - $rev = $page->doEditContent( - new WikitextContent( __METHOD__ . 'b' ), - __METHOD__ . 'b', - 0, - false, - $this->getTestUser()->getUser() - )->value['revision']; - - $store = MediaWikiServices::getInstance()->getRevisionStore(); - $record = $store->getKnownCurrentRevision( - $page->getTitle(), - $rev->getId() - ); - - $this->assertRevisionRecordMatchesRevision( $rev, $record ); - } - - public function provideNewMutableRevisionFromArray() { - yield 'Basic array, content object' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'content' => new WikitextContent( 'Some Content' ), - ] - ]; - yield 'Basic array, serialized text' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(), - ] - ]; - yield 'Basic array, serialized text, utf-8 flags' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(), - 'flags' => 'utf-8', - ] - ]; - yield 'Basic array, with title' => [ - [ - 'title' => Title::newFromText( 'SomeText' ), - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.2', - 'user' => 0, - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'content' => new WikitextContent( 'Some Content' ), - ] - ]; - yield 'Basic array, no user field' => [ - [ - 'id' => 2, - 'page' => 1, - 'timestamp' => '20171017114835', - 'user_text' => '111.0.1.3', - 'minor_edit' => false, - 'deleted' => 0, - 'len' => 46, - 'parent_id' => 1, - 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', - 'comment' => 'Goat Comment!', - 'content' => new WikitextContent( 'Some Content' ), - ] - ]; - } - - /** - * @dataProvider provideNewMutableRevisionFromArray - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray - */ - public function testNewMutableRevisionFromArray( array $array ) { - $store = MediaWikiServices::getInstance()->getRevisionStore(); - - // HACK: if $array['page'] is given, make sure that the page exists - if ( isset( $array['page'] ) ) { - $t = Title::newFromID( $array['page'] ); - if ( !$t || !$t->exists() ) { - $t = Title::makeTitle( NS_MAIN, __METHOD__ ); - $info = $this->insertPage( $t ); - $array['page'] = $info['id']; - } - } - - $result = $store->newMutableRevisionFromArray( $array ); - - if ( isset( $array['id'] ) ) { - $this->assertSame( $array['id'], $result->getId() ); - } - if ( isset( $array['page'] ) ) { - $this->assertSame( $array['page'], $result->getPageId() ); - } - $this->assertSame( $array['timestamp'], $result->getTimestamp() ); - $this->assertSame( $array['user_text'], $result->getUser()->getName() ); - if ( isset( $array['user'] ) ) { - $this->assertSame( $array['user'], $result->getUser()->getId() ); - } - $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() ); - $this->assertSame( $array['deleted'], $result->getVisibility() ); - $this->assertSame( $array['len'], $result->getSize() ); - $this->assertSame( $array['parent_id'], $result->getParentId() ); - $this->assertSame( $array['sha1'], $result->getSha1() ); - $this->assertSame( $array['comment'], $result->getComment()->text ); - if ( isset( $array['content'] ) ) { - foreach ( $array['content'] as $role => $content ) { - $this->assertTrue( - $result->getContent( $role )->equals( $content ) - ); - } - } elseif ( isset( $array['text'] ) ) { - $this->assertSame( $array['text'], - $result->getSlot( SlotRecord::MAIN )->getContent()->serialize() ); - } elseif ( isset( $array['content_format'] ) ) { - $this->assertSame( - $array['content_format'], - $result->getSlot( SlotRecord::MAIN )->getContent()->getDefaultFormat() - ); - $this->assertSame( $array['content_model'], $result->getSlot( SlotRecord::MAIN )->getModel() ); - } - } - - /** - * @dataProvider provideNewMutableRevisionFromArray - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray - */ - public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) { - $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - $blobStore = new SqlBlobStore( $lb, $cache ); - $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); - - $factory = $this->getMockBuilder( BlobStoreFactory::class ) - ->setMethods( [ 'newBlobStore', 'newSqlBlobStore' ] ) - ->disableOriginalConstructor() - ->getMock(); - $factory->expects( $this->any() ) - ->method( 'newBlobStore' ) - ->willReturn( $blobStore ); - $factory->expects( $this->any() ) - ->method( 'newSqlBlobStore' ) - ->willReturn( $blobStore ); - - $this->setService( 'BlobStoreFactory', $factory ); - - $this->testNewMutableRevisionFromArray( $array ); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionStoreFactoryTest.php b/tests/phpunit/includes/Storage/RevisionStoreFactoryTest.php deleted file mode 100644 index 1d8771be5c..0000000000 --- a/tests/phpunit/includes/Storage/RevisionStoreFactoryTest.php +++ /dev/null @@ -1,179 +0,0 @@ -getMockLoadBalancerFactory(), - $this->getMockBlobStoreFactory(), - $this->getNameTableStoreFactory(), - $this->getHashWANObjectCache(), - $this->getMockCommentStore(), - ActorMigration::newMigration(), - MIGRATION_OLD, - $this->getMockLoggerSpi(), - true - ); - $this->assertTrue( true ); - } - - public function provideWikiIds() { - yield [ true ]; - yield [ false ]; - yield [ 'somewiki' ]; - yield [ 'somewiki', MIGRATION_OLD , false ]; - yield [ 'somewiki', MIGRATION_NEW , true ]; - } - - /** - * @dataProvider provideWikiIds - */ - public function testGetRevisionStore( - $wikiId, - $mcrMigrationStage = MIGRATION_OLD, - $contentHandlerUseDb = true - ) { - $lbFactory = $this->getMockLoadBalancerFactory(); - $blobStoreFactory = $this->getMockBlobStoreFactory(); - $nameTableStoreFactory = $this->getNameTableStoreFactory(); - $cache = $this->getHashWANObjectCache(); - $commentStore = $this->getMockCommentStore(); - $actorMigration = ActorMigration::newMigration(); - $loggerProvider = $this->getMockLoggerSpi(); - - $factory = new RevisionStoreFactory( - $lbFactory, - $blobStoreFactory, - $nameTableStoreFactory, - $cache, - $commentStore, - $actorMigration, - $mcrMigrationStage, - $loggerProvider, - $contentHandlerUseDb - ); - - $store = $factory->getRevisionStore( $wikiId ); - $wrapper = TestingAccessWrapper::newFromObject( $store ); - - // ensure the correct object type is returned - $this->assertInstanceOf( RevisionStore::class, $store ); - - // ensure the RevisionStore is for the given wikiId - $this->assertSame( $wikiId, $wrapper->wikiId ); - - // ensure all other required services are correctly set - $this->assertSame( $cache, $wrapper->cache ); - $this->assertSame( $commentStore, $wrapper->commentStore ); - $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage ); - $this->assertSame( $actorMigration, $wrapper->actorMigration ); - $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() ); - - $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer ); - $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore ); - $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore ); - $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore ); - $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer - */ - private function getMockLoadBalancer() { - return $this->getMockBuilder( ILoadBalancer::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory - */ - private function getMockLoadBalancerFactory() { - $mock = $this->getMockBuilder( ILBFactory::class ) - ->disableOriginalConstructor()->getMock(); - - $mock->method( 'getMainLB' ) - ->willReturnCallback( function () { - return $this->getMockLoadBalancer(); - } ); - - return $mock; - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore - */ - private function getMockSqlBlobStore() { - return $this->getMockBuilder( SqlBlobStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory - */ - private function getMockBlobStoreFactory() { - $mock = $this->getMockBuilder( BlobStoreFactory::class ) - ->disableOriginalConstructor()->getMock(); - - $mock->method( 'newSqlBlobStore' ) - ->willReturnCallback( function () { - return $this->getMockSqlBlobStore(); - } ); - - return $mock; - } - - /** - * @return NameTableStoreFactory - */ - private function getNameTableStoreFactory() { - return new NameTableStoreFactory( - $this->getMockLoadBalancerFactory(), - $this->getHashWANObjectCache(), - new NullLogger() ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore - */ - private function getMockCommentStore() { - return $this->getMockBuilder( CommentStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - private function getHashWANObjectCache() { - return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi - */ - private function getMockLoggerSpi() { - $mock = $this->getMock( LoggerSpi::class ); - - $mock->method( 'getLogger' ) - ->willReturn( new NullLogger() ); - - return $mock; - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php b/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php deleted file mode 100644 index 12d950c2b9..0000000000 --- a/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php +++ /dev/null @@ -1,366 +0,0 @@ -resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $row = [ - 'rev_id' => '7', - 'rev_page' => strval( $title->getArticleID() ), - 'rev_timestamp' => '20200101000000', - 'rev_deleted' => 0, - 'rev_minor_edit' => 0, - 'rev_parent_id' => '5', - 'rev_len' => $slots->computeSize(), - 'rev_sha1' => $slots->computeSha1(), - 'page_latest' => '18', - ]; - - $row = array_merge( $row, $rowOverrides ); - - return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots ); - } - - public function provideConstructor() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $protoRow = [ - 'rev_id' => '7', - 'rev_page' => strval( $title->getArticleID() ), - 'rev_timestamp' => '20200101000000', - 'rev_deleted' => 0, - 'rev_minor_edit' => 0, - 'rev_parent_id' => '5', - 'rev_len' => $slots->computeSize(), - 'rev_sha1' => $slots->computeSha1(), - 'page_latest' => '18', - ]; - - $row = $protoRow; - yield 'all info' => [ - $title, - $user, - $comment, - (object)$row, - $slots, - 'acmewiki' - ]; - - $row = $protoRow; - $row['rev_minor_edit'] = '1'; - $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER ); - - yield 'minor deleted' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - $row['page_latest'] = $row['rev_id']; - - yield 'latest' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - unset( $row['rev_parent'] ); - - yield 'no parent' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - $row['rev_len'] = null; - $row['rev_sha1'] = ''; - - yield 'rev_len is null, rev_sha1 is ""' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - yield 'no length, no hash' => [ - Title::newFromText( 'DummyDoesNotExist' ), - $user, - $comment, - (object)$row, - $slots - ]; - } - - /** - * @dataProvider provideConstructor - * - * @param Title $title - * @param UserIdentity $user - * @param CommentStoreComment $comment - * @param object $row - * @param RevisionSlots $slots - * @param bool $wikiId - */ - public function testConstructorAndGetters( - Title $title, - UserIdentity $user, - CommentStoreComment $comment, - $row, - RevisionSlots $slots, - $wikiId = false - ) { - $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId ); - - $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' ); - $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' ); - $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' ); - $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' ); - $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' ); - $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' ); - - if ( isset( $row->rev_parent_id ) ) { - $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' ); - } else { - $this->assertSame( 0, $rec->getParentId(), 'getParentId' ); - } - - if ( isset( $row->rev_len ) ) { - $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' ); - } else { - $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' ); - } - - if ( !empty( $row->rev_sha1 ) ) { - $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' ); - } else { - $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' ); - } - - if ( isset( $row->page_latest ) ) { - $this->assertSame( - (int)$row->rev_id === (int)$row->page_latest, - $rec->isCurrent(), - 'isCurrent' - ); - } else { - $this->assertSame( - false, - $rec->isCurrent(), - 'isCurrent' - ); - } - } - - public function provideConstructorFailure() { - $title = Title::newFromText( 'Dummy' ); - $title->resetArticleID( 17 ); - - $user = new UserIdentityValue( 11, 'Tester', 0 ); - - $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); - - $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) ); - $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) ); - $slots = new RevisionSlots( [ $main, $aux ] ); - - $protoRow = [ - 'rev_id' => '7', - 'rev_page' => strval( $title->getArticleID() ), - 'rev_timestamp' => '20200101000000', - 'rev_deleted' => 0, - 'rev_minor_edit' => 0, - 'rev_parent_id' => '5', - 'rev_len' => $slots->computeSize(), - 'rev_sha1' => $slots->computeSha1(), - 'page_latest' => '18', - ]; - - yield 'not a row' => [ - $title, - $user, - $comment, - 'not a row', - $slots, - 'acmewiki' - ]; - - $row = $protoRow; - $row['rev_timestamp'] = 'kittens'; - - yield 'bad timestamp' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - $row['rev_page'] = 99; - - yield 'page ID mismatch' => [ - $title, - $user, - $comment, - (object)$row, - $slots - ]; - - $row = $protoRow; - - yield 'bad wiki' => [ - $title, - $user, - $comment, - (object)$row, - $slots, - 12345 - ]; - } - - /** - * @dataProvider provideConstructorFailure - * - * @param Title $title - * @param UserIdentity $user - * @param CommentStoreComment $comment - * @param object $row - * @param RevisionSlots $slots - * @param bool $wikiId - */ - public function testConstructorFailure( - Title $title, - UserIdentity $user, - CommentStoreComment $comment, - $row, - RevisionSlots $slots, - $wikiId = false - ) { - $this->setExpectedException( InvalidArgumentException::class ); - new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId ); - } - - public function provideIsCurrent() { - yield [ - [ - 'rev_id' => 11, - 'page_latest' => 11, - ], - true, - ]; - yield [ - [ - 'rev_id' => 11, - 'page_latest' => 10, - ], - false, - ]; - } - - /** - * @dataProvider provideIsCurrent - */ - public function testIsCurrent( $row, $current ) { - $rev = $this->newRevision( $row ); - - $this->assertSame( $current, $rev->isCurrent(), 'isCurrent()' ); - } - - public function provideGetSlot_audience_latest() { - return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT ); - } - - /** - * @dataProvider provideGetSlot_audience_latest - */ - public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) { - $this->forceStandardPermissions(); - - $user = $this->getTestUser( $groups )->getUser(); - $rev = $this->newRevision( - [ - 'rev_deleted' => $visibility, - 'rev_id' => 11, - 'page_latest' => 11, // revision is current - ] - ); - - // NOTE: slot meta-data is never suppressed, just the content is! - $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' ); - $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ), - 'public can' ); - - $this->assertNotNull( - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ), - 'user can' - ); - - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getContent(); - // NOTE: the content of the current revision is never suppressed! - // Check that getContent() doesn't throw SuppressedDataException - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent(); - $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent(); - } - -} diff --git a/tests/phpunit/includes/Storage/RevisionStoreTest.php b/tests/phpunit/includes/Storage/RevisionStoreTest.php deleted file mode 100644 index 2ed6f28495..0000000000 --- a/tests/phpunit/includes/Storage/RevisionStoreTest.php +++ /dev/null @@ -1,566 +0,0 @@ -getMockLoadBalancer(), - $blobStore ?: $this->getMockSqlBlobStore(), - $WANObjectCache ?: $this->getHashWANObjectCache(), - MediaWikiServices::getInstance()->getCommentStore(), - MediaWikiServices::getInstance()->getContentModelStore(), - MediaWikiServices::getInstance()->getSlotRoleStore(), - $wgMultiContentRevisionSchemaMigrationStage, - MediaWikiServices::getInstance()->getActorMigration() - ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer - */ - private function getMockLoadBalancer() { - return $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Database - */ - private function getMockDatabase() { - return $this->getMockBuilder( Database::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore - */ - private function getMockSqlBlobStore() { - return $this->getMockBuilder( SqlBlobStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore - */ - private function getMockCommentStore() { - return $this->getMockBuilder( CommentStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - private function getHashWANObjectCache() { - return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); - } - - public function provideSetContentHandlerUseDB() { - return [ - // ContentHandlerUseDB can be true of false pre migration. - [ false, SCHEMA_COMPAT_OLD, false ], - [ true, SCHEMA_COMPAT_OLD, false ], - // During and after migration it can not be false... - [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, true ], - [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, true ], - [ false, SCHEMA_COMPAT_NEW, true ], - // ...but it can be true. - [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ], - [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ], - [ true, SCHEMA_COMPAT_NEW, false ], - ]; - } - - /** - * @dataProvider provideSetContentHandlerUseDB - * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB - * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB - */ - public function testSetContentHandlerUseDB( $contentHandlerDb, $migrationMode, $expectedFail ) { - if ( $expectedFail ) { - $this->setExpectedException( MWException::class ); - } - - $nameTables = MediaWikiServices::getInstance()->getNameTableStoreFactory(); - - $store = new RevisionStore( - $this->getMockLoadBalancer(), - $this->getMockSqlBlobStore(), - $this->getHashWANObjectCache(), - $this->getMockCommentStore(), - $nameTables->getContentModels(), - $nameTables->getSlotRoles(), - $migrationMode, - MediaWikiServices::getInstance()->getActorMigration() - ); - - $store->setContentHandlerUseDB( $contentHandlerDb ); - $this->assertSame( $contentHandlerDb, $store->getContentHandlerUseDB() ); - } - - public function testGetTitle_successFromPageId() { - $mockLoadBalancer = $this->getMockLoadBalancer(); - // Title calls wfGetDB() so we have to set the main service - $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); - - $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnection' ) - ->willReturn( $db ); - - // First call to Title::newFromID, faking no result (db lag?) - $db->expects( $this->at( 0 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( (object)[ - 'page_namespace' => '1', - 'page_title' => 'Food', - ] ); - - $store = $this->getRevisionStore( $mockLoadBalancer ); - $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); - - $this->assertSame( 1, $title->getNamespace() ); - $this->assertSame( 'Food', $title->getDBkey() ); - } - - public function testGetTitle_successFromPageIdOnFallback() { - $mockLoadBalancer = $this->getMockLoadBalancer(); - // Title calls wfGetDB() so we have to set the main service - $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); - - $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - // Assert that the first call uses a REPLICA and the second falls back to master - $mockLoadBalancer->expects( $this->exactly( 2 ) ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnectionRef' ) - ->willReturn( $db ); - - // First call to Title::newFromID, faking no result (db lag?) - $db->expects( $this->at( 0 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( false ); - - // First select using rev_id, faking no result (db lag?) - $db->expects( $this->at( 1 ) ) - ->method( 'selectRow' ) - ->with( - [ 'revision', 'page' ], - $this->anything(), - [ 'rev_id' => 2 ] - ) - ->willReturn( false ); - - // Second call to Title::newFromID, no result - $db->expects( $this->at( 2 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( (object)[ - 'page_namespace' => '2', - 'page_title' => 'Foodey', - ] ); - - $store = $this->getRevisionStore( $mockLoadBalancer ); - $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); - - $this->assertSame( 2, $title->getNamespace() ); - $this->assertSame( 'Foodey', $title->getDBkey() ); - } - - public function testGetTitle_successFromRevId() { - $mockLoadBalancer = $this->getMockLoadBalancer(); - // Title calls wfGetDB() so we have to set the main service - $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); - - $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnectionRef' ) - ->willReturn( $db ); - - // First call to Title::newFromID, faking no result (db lag?) - $db->expects( $this->at( 0 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( false ); - - // First select using rev_id, faking no result (db lag?) - $db->expects( $this->at( 1 ) ) - ->method( 'selectRow' ) - ->with( - [ 'revision', 'page' ], - $this->anything(), - [ 'rev_id' => 2 ] - ) - ->willReturn( (object)[ - 'page_namespace' => '1', - 'page_title' => 'Food2', - ] ); - - $store = $this->getRevisionStore( $mockLoadBalancer ); - $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); - - $this->assertSame( 1, $title->getNamespace() ); - $this->assertSame( 'Food2', $title->getDBkey() ); - } - - public function testGetTitle_successFromRevIdOnFallback() { - $mockLoadBalancer = $this->getMockLoadBalancer(); - // Title calls wfGetDB() so we have to set the main service - $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); - - $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - // Assert that the first call uses a REPLICA and the second falls back to master - $mockLoadBalancer->expects( $this->exactly( 2 ) ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnectionRef' ) - ->willReturn( $db ); - - // First call to Title::newFromID, faking no result (db lag?) - $db->expects( $this->at( 0 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( false ); - - // First select using rev_id, faking no result (db lag?) - $db->expects( $this->at( 1 ) ) - ->method( 'selectRow' ) - ->with( - [ 'revision', 'page' ], - $this->anything(), - [ 'rev_id' => 2 ] - ) - ->willReturn( false ); - - // Second call to Title::newFromID, no result - $db->expects( $this->at( 2 ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( false ); - - // Second select using rev_id, result - $db->expects( $this->at( 3 ) ) - ->method( 'selectRow' ) - ->with( - [ 'revision', 'page' ], - $this->anything(), - [ 'rev_id' => 2 ] - ) - ->willReturn( (object)[ - 'page_namespace' => '2', - 'page_title' => 'Foodey', - ] ); - - $store = $this->getRevisionStore( $mockLoadBalancer ); - $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); - - $this->assertSame( 2, $title->getNamespace() ); - $this->assertSame( 'Foodey', $title->getDBkey() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::getTitle - */ - public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() { - $mockLoadBalancer = $this->getMockLoadBalancer(); - // Title calls wfGetDB() so we have to set the main service - $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); - - $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - // Assert that the first call uses a REPLICA and the second falls back to master - - // RevisionStore getTitle uses getConnectionRef - // Title::newFromID uses getConnection - foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) { - $mockLoadBalancer->expects( $this->exactly( 2 ) ) - ->method( $method ) - ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) { - static $callCounter = 0; - $callCounter++; - // The first call should be to a REPLICA, and the second a MASTER. - if ( $callCounter === 1 ) { - $this->assertSame( DB_REPLICA, $masterOrReplica ); - } elseif ( $callCounter === 2 ) { - $this->assertSame( DB_MASTER, $masterOrReplica ); - } - return $db; - } ); - } - // First and third call to Title::newFromID, faking no result - foreach ( [ 0, 2 ] as $counter ) { - $db->expects( $this->at( $counter ) ) - ->method( 'selectRow' ) - ->with( - 'page', - $this->anything(), - [ 'page_id' => 1 ] - ) - ->willReturn( false ); - } - - foreach ( [ 1, 3 ] as $counter ) { - $db->expects( $this->at( $counter ) ) - ->method( 'selectRow' ) - ->with( - [ 'revision', 'page' ], - $this->anything(), - [ 'rev_id' => 2 ] - ) - ->willReturn( false ); - } - - $store = $this->getRevisionStore( $mockLoadBalancer ); - - $this->setExpectedException( RevisionAccessException::class ); - $store->getTitle( 1, 2, RevisionStore::READ_NORMAL ); - } - - public function provideNewRevisionFromRow_legacyEncoding_applied() { - yield 'windows-1252, old_flags is empty' => [ - 'windows-1252', - 'en', - [ - 'old_flags' => '', - 'old_text' => "S\xF6me Content", - ], - 'Söme Content' - ]; - - yield 'windows-1252, old_flags is null' => [ - 'windows-1252', - 'en', - [ - 'old_flags' => null, - 'old_text' => "S\xF6me Content", - ], - 'Söme Content' - ]; - } - - /** - * @dataProvider provideNewRevisionFromRow_legacyEncoding_applied - * - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) { - if ( !$this->useTextId() ) { - $this->markTestSkipped( 'No longer applicable with MCR schema' ); - } - - $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - - $blobStore = new SqlBlobStore( $lb, $cache ); - $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) ); - - $store = $this->getRevisionStore( $lb, $blobStore, $cache ); - - $record = $store->newRevisionFromRow( - $this->makeRow( $row ), - 0, - Title::newFromText( __METHOD__ . '-UTPage' ) - ); - - $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() ); - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow - */ - public function testNewRevisionFromRow_legacyEncoding_ignored() { - if ( !$this->useTextId() ) { - $this->markTestSkipped( 'No longer applicable with MCR schema' ); - } - - $row = [ - 'old_flags' => 'utf-8', - 'old_text' => 'Söme Content', - ]; - - $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - - $blobStore = new SqlBlobStore( $lb, $cache ); - $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); - - $store = $this->getRevisionStore( $lb, $blobStore, $cache ); - - $record = $store->newRevisionFromRow( - $this->makeRow( $row ), - 0, - Title::newFromText( __METHOD__ . '-UTPage' ) - ); - $this->assertSame( 'Söme Content', $record->getContent( SlotRecord::MAIN )->serialize() ); - } - - private function makeRow( array $array ) { - $row = $array + [ - 'rev_id' => 7, - 'rev_page' => 5, - 'rev_timestamp' => '20110101000000', - 'rev_user_text' => 'Tester', - 'rev_user' => 17, - 'rev_minor_edit' => 0, - 'rev_deleted' => 0, - 'rev_len' => 100, - 'rev_parent_id' => 0, - 'rev_sha1' => 'deadbeef', - 'rev_comment_text' => 'Testing', - 'rev_comment_data' => '{}', - 'rev_comment_cid' => 111, - 'page_namespace' => 0, - 'page_title' => 'TEST', - 'page_id' => 5, - 'page_latest' => 7, - 'page_is_redirect' => 0, - 'page_len' => 100, - 'user_name' => 'Tester', - ]; - - if ( $this->useTextId() ) { - $row += [ - 'rev_content_format' => CONTENT_FORMAT_TEXT, - 'rev_content_model' => CONTENT_MODEL_TEXT, - 'rev_text_id' => 11, - 'old_id' => 11, - 'old_text' => 'Hello World', - 'old_flags' => 'utf-8', - ]; - } else { - if ( !isset( $row['content'] ) && isset( $array['old_text'] ) ) { - $row['content'] = [ - 'main' => new WikitextContent( $array['old_text'] ), - ]; - } - } - - return (object)$row; - } - - public function provideMigrationConstruction() { - return [ - [ SCHEMA_COMPAT_OLD, false ], - [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ], - [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ], - [ SCHEMA_COMPAT_NEW, false ], - [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_BOTH, true ], - [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, true ], - [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_BOTH, true ], - ]; - } - - /** - * @covers \MediaWiki\Storage\RevisionStore::__construct - * @dataProvider provideMigrationConstruction - */ - public function testMigrationConstruction( $migration, $expectException ) { - if ( $expectException ) { - $this->setExpectedException( InvalidArgumentException::class ); - } - $loadBalancer = $this->getMockLoadBalancer(); - $blobStore = $this->getMockSqlBlobStore(); - $cache = $this->getHashWANObjectCache(); - $commentStore = $this->getMockCommentStore(); - $services = MediaWikiServices::getInstance(); - $nameTables = $services->getNameTableStoreFactory(); - $contentModelStore = $nameTables->getContentModels(); - $slotRoleStore = $nameTables->getSlotRoles(); - $store = new RevisionStore( - $loadBalancer, - $blobStore, - $cache, - $commentStore, - $nameTables->getContentModels(), - $nameTables->getSlotRoles(), - $migration, - $services->getActorMigration() - ); - if ( !$expectException ) { - $store = TestingAccessWrapper::newFromObject( $store ); - $this->assertSame( $loadBalancer, $store->loadBalancer ); - $this->assertSame( $blobStore, $store->blobStore ); - $this->assertSame( $cache, $store->cache ); - $this->assertSame( $commentStore, $store->commentStore ); - $this->assertSame( $contentModelStore, $store->contentModelStore ); - $this->assertSame( $slotRoleStore, $store->slotRoleStore ); - $this->assertSame( $migration, $store->mcrMigrationStage ); - } - } - -} diff --git a/tests/phpunit/includes/Storage/SlotRecordTest.php b/tests/phpunit/includes/Storage/SlotRecordTest.php deleted file mode 100644 index 0db294e675..0000000000 --- a/tests/phpunit/includes/Storage/SlotRecordTest.php +++ /dev/null @@ -1,408 +0,0 @@ - 1234, - 'slot_content_id' => 33, - 'content_size' => '5', - 'content_sha1' => 'someHash', - 'content_address' => 'tt:456', - 'model_name' => CONTENT_MODEL_WIKITEXT, - 'format_name' => CONTENT_FORMAT_WIKITEXT, - 'slot_revision_id' => '2', - 'slot_origin' => '1', - 'role_name' => 'myRole', - ]; - return (object)$data; - } - - public function testCompleteConstruction() { - $row = $this->makeRow(); - $record = new SlotRecord( $row, new WikitextContent( 'A' ) ); - - $this->assertTrue( $record->hasAddress() ); - $this->assertTrue( $record->hasContentId() ); - $this->assertTrue( $record->hasRevision() ); - $this->assertTrue( $record->isInherited() ); - $this->assertSame( 'A', $record->getContent()->getNativeData() ); - $this->assertSame( 5, $record->getSize() ); - $this->assertSame( 'someHash', $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 1, $record->getOrigin() ); - $this->assertSame( 'tt:456', $record->getAddress() ); - $this->assertSame( 33, $record->getContentId() ); - $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function testConstructionDeferred() { - $row = $this->makeRow( [ - 'content_size' => null, // to be computed - 'content_sha1' => null, // to be computed - 'format_name' => function () { - return CONTENT_FORMAT_WIKITEXT; - }, - 'slot_revision_id' => '2', - 'slot_origin' => '2', - 'slot_content_id' => function () { - return null; - }, - ] ); - - $content = function () { - return new WikitextContent( 'A' ); - }; - - $record = new SlotRecord( $row, $content ); - - $this->assertTrue( $record->hasAddress() ); - $this->assertTrue( $record->hasRevision() ); - $this->assertFalse( $record->hasContentId() ); - $this->assertFalse( $record->isInherited() ); - $this->assertSame( 'A', $record->getContent()->getNativeData() ); - $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 'tt:456', $record->getAddress() ); - $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function testNewUnsaved() { - $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) ); - - $this->assertFalse( $record->hasAddress() ); - $this->assertFalse( $record->hasContentId() ); - $this->assertFalse( $record->hasRevision() ); - $this->assertFalse( $record->isInherited() ); - $this->assertFalse( $record->hasOrigin() ); - $this->assertSame( 'A', $record->getContent()->getNativeData() ); - $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function provideInvalidConstruction() { - yield 'both null' => [ null, null ]; - yield 'null row' => [ null, new WikitextContent( 'A' ) ]; - yield 'array row' => [ [], new WikitextContent( 'A' ) ]; - yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ]; - yield 'null content' => [ (object)[], null ]; - } - - /** - * @dataProvider provideInvalidConstruction - */ - public function testInvalidConstruction( $row, $content ) { - $this->setExpectedException( InvalidArgumentException::class ); - new SlotRecord( $row, $content ); - } - - public function testGetContentId_fails() { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getContentId(); - } - - public function testGetAddress_fails() { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getAddress(); - } - - public function provideIncomplete() { - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - yield 'unsaved' => [ $unsaved ]; - - $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); - $inherited = SlotRecord::newInherited( $parent ); - yield 'inherited' => [ $inherited ]; - } - - /** - * @dataProvider provideIncomplete - */ - public function testGetRevision_fails( SlotRecord $record ) { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getRevision(); - } - - /** - * @dataProvider provideIncomplete - */ - public function testGetOrigin_fails( SlotRecord $record ) { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getOrigin(); - } - - public function provideHashStability() { - yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ]; - yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ]; - } - - /** - * @dataProvider provideHashStability - */ - public function testHashStability( $text, $hash ) { - // Changing the output of the hash function will break things horribly! - - $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) ); - - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) ); - $this->assertSame( $hash, $record->getSha1() ); - } - - public function testNewWithSuppressedContent() { - $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); - $output = SlotRecord::newWithSuppressedContent( $input ); - - $this->setExpectedException( SuppressedDataException::class ); - $output->getContent(); - } - - public function testNewInherited() { - $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] ); - $parent = new SlotRecord( $row, new WikitextContent( 'A' ) ); - - // This would happen while doing an edit, before saving revision meta-data. - $inherited = SlotRecord::newInherited( $parent ); - - $this->assertSame( $parent->getContentId(), $inherited->getContentId() ); - $this->assertSame( $parent->getAddress(), $inherited->getAddress() ); - $this->assertSame( $parent->getContent(), $inherited->getContent() ); - $this->assertTrue( $inherited->isInherited() ); - $this->assertTrue( $inherited->hasOrigin() ); - $this->assertFalse( $inherited->hasRevision() ); - - // make sure we didn't mess with the internal state of $parent - $this->assertFalse( $parent->isInherited() ); - $this->assertSame( 7, $parent->getRevision() ); - - // This would happen while doing an edit, after saving the revision meta-data - // and content meta-data. - $saved = SlotRecord::newSaved( - 10, - $inherited->getContentId(), - $inherited->getAddress(), - $inherited - ); - $this->assertSame( $parent->getContentId(), $saved->getContentId() ); - $this->assertSame( $parent->getAddress(), $saved->getAddress() ); - $this->assertSame( $parent->getContent(), $saved->getContent() ); - $this->assertTrue( $saved->isInherited() ); - $this->assertTrue( $saved->hasRevision() ); - $this->assertSame( 10, $saved->getRevision() ); - - // make sure we didn't mess with the internal state of $parent or $inherited - $this->assertSame( 7, $parent->getRevision() ); - $this->assertFalse( $inherited->hasRevision() ); - } - - public function testNewSaved() { - // This would happen while doing an edit, before saving revision meta-data. - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - - // This would happen while doing an edit, after saving the revision meta-data - // and content meta-data. - $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved ); - $this->assertFalse( $saved->isInherited() ); - $this->assertTrue( $saved->hasOrigin() ); - $this->assertTrue( $saved->hasRevision() ); - $this->assertTrue( $saved->hasAddress() ); - $this->assertTrue( $saved->hasContentId() ); - $this->assertSame( 'theNewAddress', $saved->getAddress() ); - $this->assertSame( 20, $saved->getContentId() ); - $this->assertSame( 'A', $saved->getContent()->getNativeData() ); - $this->assertSame( 10, $saved->getRevision() ); - $this->assertSame( 10, $saved->getOrigin() ); - - // make sure we didn't mess with the internal state of $unsaved - $this->assertFalse( $unsaved->hasAddress() ); - $this->assertFalse( $unsaved->hasContentId() ); - $this->assertFalse( $unsaved->hasRevision() ); - } - - public function provideNewSaved_LogicException() { - $freshRow = $this->makeRow( [ - 'content_id' => 10, - 'content_address' => 'address:1', - 'slot_origin' => 1, - 'slot_revision_id' => 1, - ] ); - - $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) ); - yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ]; - yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ]; - yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ]; - - $inheritedRow = $this->makeRow( [ - 'content_id' => null, - 'content_address' => null, - 'slot_origin' => 0, - 'slot_revision_id' => 1, - ] ); - - $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) ); - yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ]; - } - - /** - * @dataProvider provideNewSaved_LogicException - */ - public function testNewSaved_LogicException( - $revisionId, - $contentId, - $contentAddress, - SlotRecord $protoSlot - ) { - $this->setExpectedException( LogicException::class ); - SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); - } - - public function provideNewSaved_InvalidArgumentException() { - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - - yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ]; - yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ]; - yield 'bad content address' => [ 7, 5, 77, $unsaved ]; - } - - /** - * @dataProvider provideNewSaved_InvalidArgumentException - */ - public function testNewSaved_InvalidArgumentException( - $revisionId, - $contentId, - $contentAddress, - SlotRecord $protoSlot - ) { - $this->setExpectedException( InvalidArgumentException::class ); - SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); - } - - public function provideHasSameContent() { - $fail = function () { - self::fail( 'There should be no need to actually load the content.' ); - }; - - $a100a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a100a1b = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a100null = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => null, - ] - ), - $fail - ); - $a100a2 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a2', - ] - ), - $fail - ); - $b100a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'B', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a200a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 200, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a2', - ] - ), - $fail - ); - $a100x1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-x', - 'content_address' => 'xxx:x1', - ] - ), - $fail - ); - - yield 'same instance' => [ $a100a1, $a100a1, true ]; - yield 'no address' => [ $a100a1, $a100null, true ]; - yield 'same address' => [ $a100a1, $a100a1b, true ]; - yield 'different address' => [ $a100a1, $a100a2, true ]; - yield 'different model' => [ $a100a1, $b100a1, false ]; - yield 'different size' => [ $a100a1, $a200a1, false ]; - yield 'different hash' => [ $a100a1, $a100x1, false ]; - } - - /** - * @dataProvider provideHasSameContent - */ - public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) { - $this->assertSame( $sameContent, $a->hasSameContent( $b ) ); - $this->assertSame( $sameContent, $b->hasSameContent( $a ) ); - } - -} diff --git a/tests/phpunit/includes/Storage/create-pre-mcr-fields.sql b/tests/phpunit/includes/Storage/create-pre-mcr-fields.sql deleted file mode 100644 index 09deb4f2cd..0000000000 --- a/tests/phpunit/includes/Storage/create-pre-mcr-fields.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE /*_*/revision ADD rev_text_id INTEGER DEFAULT 0; -ALTER TABLE /*_*/revision ADD rev_content_model VARBINARY(32) DEFAULT NULL; -ALTER TABLE /*_*/revision ADD rev_content_format VARBINARY(64) DEFAULT NULL; diff --git a/tests/phpunit/includes/Storage/drop-mcr-tables.sql b/tests/phpunit/includes/Storage/drop-mcr-tables.sql deleted file mode 100644 index bc89edc95e..0000000000 --- a/tests/phpunit/includes/Storage/drop-mcr-tables.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP TABLE /*_*/slots; -DROP TABLE /*_*/content; -DROP TABLE /*_*/content_models; -DROP TABLE /*_*/slot_roles; diff --git a/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sql b/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sql deleted file mode 100644 index ddfe756f8e..0000000000 --- a/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE /*_*/revision DROP COLUMN rev_text_id; -ALTER TABLE /*_*/revision DROP COLUMN rev_content_model; -ALTER TABLE /*_*/revision DROP COLUMN rev_content_format; diff --git a/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sqlite.sql b/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sqlite.sql deleted file mode 100644 index ce7a61861d..0000000000 --- a/tests/phpunit/includes/Storage/drop-pre-mcr-fields.sqlite.sql +++ /dev/null @@ -1,15 +0,0 @@ -DROP TABLE /*_*/revision; - -CREATE TABLE /*_*/revision ( - rev_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - rev_page INTEGER NOT NULL, - rev_comment BLOB NOT NULL, - rev_user INTEGER NOT NULL default 0, - rev_user_text varchar(255) NOT NULL default '', - rev_timestamp blob(14) NOT NULL default '', - rev_minor_edit INTEGER NOT NULL default 0, - rev_deleted INTEGER NOT NULL default 0, - rev_len INTEGER unsigned, - rev_parent_id INTEGER default NULL, - rev_sha1 varbinary(32) NOT NULL default '' -) /*$wgDBTableOptions*/; diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php index 8e2c6d95d5..41ecd52944 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -2,7 +2,7 @@ use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; -use MediaWiki\Storage\SlotRecord; +use MediaWiki\Revision\SlotRecord; /** * @group medium diff --git a/tests/phpunit/includes/content/WikitextContentHandlerTest.php b/tests/phpunit/includes/content/WikitextContentHandlerTest.php index e469f1232b..31d90cb568 100644 --- a/tests/phpunit/includes/content/WikitextContentHandlerTest.php +++ b/tests/phpunit/includes/content/WikitextContentHandlerTest.php @@ -1,8 +1,8 @@