From: daniel Date: Thu, 31 Aug 2017 18:41:04 +0000 (+0200) Subject: MCR: Deprecate and gut Revision class X-Git-Tag: 1.31.0-rc.0~1143^2 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=6af796f3e0cf3e66cd7d7e59af8445f5712d68fe MCR: Deprecate and gut Revision class This is a re-submission of I4f24e7fbb68. As a first major step towards Multi-Content-Revisions (MCR), this patch turns the Revision class into a legacy proxy for the new RevisionRecord and RevisionStore classes. Backwards compatibility is maintained for all but some rare edge cases, like constructing a completely empty Revision object. For more information on MCR, see . NOTE: once this is merged, verify create/delete/restore cycle on beta, ideally with emulated replication lag. Bug: T174025 Change-Id: Ia4c20a91e98df0b9b14b138eb4825c55e5200384 --- diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index 1a1a9f71e6..7f67feb8b7 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -71,6 +71,10 @@ changes to languages because of Phabricator reports. * (T180052) Mirandese (mwl) now supports gendered NS_USER/NS_USER_TALK namespaces. === Other changes in 1.31 === +* Introducing multi-content-revision capability into the storage layer. For details, + see . +* The Revision class was deprecated in favor of RevisionStore, BlobStore, and + RevisionRecord and its subclasses. * MessageBlobStore::insertMessageBlob() (deprecated in 1.27) was removed. * The global function wfBCP47 was renamed to LanguageCode::bcp47. * The global function wfBCP47 is now deprecated. @@ -123,6 +127,9 @@ changes to languages because of Phabricator reports. * The Block class will no longer accept usable-but-missing usernames for 'byText' or ->setBlocker(). Callers should either ensure the blocker exists locally or use a new interwiki-format username like "iw>Example". +* The RevisionInsertComplete hook is now deprecated, use RevisionRecordInserted instead. + RevisionInsertComplete is still called, but the second and third parameter will always be null. + Hard deprecation is scheduled for 1.32. * The following methods that get and set ParserOutput state are deprecated. Callers should use the new stateless $options parameter to ParserOutput::getText() instead. diff --git a/docs/hooks.txt b/docs/hooks.txt index ee38ea9d4a..1f4a5f4da4 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -2810,14 +2810,14 @@ called after the addition of 'qunit' and MediaWiki testing resources. added to any module. &$ResourceLoader: object -'RevisionInsertComplete': Called after a revision is inserted into the database. -&$revision: the Revision -$data: the data stored in old_text. The meaning depends on $flags: if external - is set, it's the URL of the revision text in external storage; otherwise, - it's the revision text itself. In either case, if gzip is set, the revision - text is gzipped. -$flags: a comma-delimited list of strings representing the options used. May - include: utf8 (this will always be set for new revisions); gzip; external. +'RevisionRecordInserted': Called after a revision is inserted into the database. +$revisionRecord: the RevisionRecord that has just been inserted. + +'RevisionInsertComplete': DEPRECATED! Use RevisionRecordInserted hook instead. +Called after a revision is inserted into the database. +$revision: the Revision +$data: DEPRECATED! Always null! +$flags: DEPRECATED! Always null! 'SearchableNamespaces': An option to modify which namespaces are searchable. &$arr: Array of namespaces ($nsId => $name) which will be used. diff --git a/includes/Revision.php b/includes/Revision.php index 25c89c26ec..ea73a61bbf 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -20,7 +20,14 @@ * @file */ -use Wikimedia\Rdbms\Database; +use MediaWiki\Storage\MutableRevisionRecord; +use MediaWiki\Storage\RevisionAccessException; +use MediaWiki\Storage\RevisionRecord; +use MediaWiki\Storage\RevisionStore; +use MediaWiki\Storage\RevisionStoreRecord; +use MediaWiki\Storage\SlotRecord; +use MediaWiki\Storage\SqlBlobStore; +use MediaWiki\User\UserIdentityValue; use Wikimedia\Rdbms\IDatabase; use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; @@ -28,78 +35,50 @@ use Wikimedia\Rdbms\ResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; /** - * @todo document + * @deprecated since 1.31, use RevisionRecord, RevisionStore, and BlobStore instead. */ class Revision implements IDBAccessObject { - /** @var int|null */ - protected $mId; - /** @var int|null */ - protected $mPage; - /** @var string */ - protected $mUserText; - /** @var string */ - protected $mOrigUserText; - /** @var int */ - protected $mUser; - /** @var bool */ - protected $mMinorEdit; - /** @var string */ - protected $mTimestamp; - /** @var int */ - protected $mDeleted; - /** @var int */ - protected $mSize; - /** @var string */ - protected $mSha1; - /** @var int */ - protected $mParentId; - /** @var string */ - protected $mComment; - /** @var string */ - protected $mText; - /** @var int */ - protected $mTextId; - /** @var int */ - protected $mUnpatrolled; - - /** @var stdClass|null */ - protected $mTextRow; - - /** @var null|Title */ - protected $mTitle; - /** @var bool */ - protected $mCurrent; - /** @var string */ - protected $mContentModel; - /** @var string */ - protected $mContentFormat; - - /** @var Content|null|bool */ - protected $mContent; - /** @var null|ContentHandler */ - protected $mContentHandler; - - /** @var int */ - protected $mQueryFlags = 0; - /** @var bool Used for cached values to reload user text and rev_deleted */ - protected $mRefreshMutableFields = false; - /** @var string Wiki ID; false means the current wiki */ - protected $mWiki = false; + + /** @var RevisionRecord */ + protected $mRecord; // Revision deletion constants - const DELETED_TEXT = 1; - const DELETED_COMMENT = 2; - const DELETED_USER = 4; - const DELETED_RESTRICTED = 8; - const SUPPRESSED_USER = 12; // convenience - const SUPPRESSED_ALL = 15; // convenience + const DELETED_TEXT = RevisionRecord::DELETED_TEXT; + const DELETED_COMMENT = RevisionRecord::DELETED_COMMENT; + const DELETED_USER = RevisionRecord::DELETED_USER; + const DELETED_RESTRICTED = RevisionRecord::DELETED_RESTRICTED; + const SUPPRESSED_USER = RevisionRecord::SUPPRESSED_USER; + const SUPPRESSED_ALL = RevisionRecord::SUPPRESSED_ALL; // Audience options for accessors - const FOR_PUBLIC = 1; - const FOR_THIS_USER = 2; - const RAW = 3; + const FOR_PUBLIC = RevisionRecord::FOR_PUBLIC; + const FOR_THIS_USER = RevisionRecord::FOR_THIS_USER; + const RAW = RevisionRecord::RAW; + + const TEXT_CACHE_GROUP = SqlBlobStore::TEXT_CACHE_GROUP; - const TEXT_CACHE_GROUP = 'revisiontext:10'; // process cache name and max key count + /** + * @return RevisionStore + */ + protected static function getRevisionStore() { + return MediaWikiServices::getInstance()->getRevisionStore(); + } + + /** + * @return SqlBlobStore + */ + protected static function getBlobStore() { + $store = MediaWikiServices::getInstance()->getBlobStore(); + + if ( !$store instanceof SqlBlobStore ) { + throw new RuntimeException( + 'The backwards compatibility code in Revision currently requires the BlobStore ' + . 'service to be an SqlBlobStore instance, but it is a ' . get_class( $store ) + ); + } + + return $store; + } /** * Load a page revision from a given revision ID number. @@ -114,7 +93,8 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromId( $id, $flags = 0 ) { - return self::newFromConds( [ 'rev_id' => intval( $id ) ], $flags ); + $rec = self::getRevisionStore()->getRevisionById( $id, $flags ); + return $rec === null ? null : new Revision( $rec, $flags ); } /** @@ -132,20 +112,8 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromTitle( LinkTarget $linkTarget, $id = 0, $flags = 0 ) { - $conds = [ - 'page_namespace' => $linkTarget->getNamespace(), - 'page_title' => $linkTarget->getDBkey() - ]; - if ( $id ) { - // Use the specified ID - $conds['rev_id'] = $id; - return self::newFromConds( $conds, $flags ); - } else { - // Use a join to get the latest revision - $conds[] = 'rev_id=page_latest'; - $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); - return self::loadFromConds( $db, $conds, $flags ); - } + $rec = self::getRevisionStore()->getRevisionByTitle( $linkTarget, $id, $flags ); + return $rec === null ? null : new Revision( $rec, $flags ); } /** @@ -163,22 +131,13 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromPageId( $pageId, $revId = 0, $flags = 0 ) { - $conds = [ 'page_id' => $pageId ]; - if ( $revId ) { - $conds['rev_id'] = $revId; - return self::newFromConds( $conds, $flags ); - } else { - // Use a join to get the latest revision - $conds[] = 'rev_id = page_latest'; - $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); - return self::loadFromConds( $db, $conds, $flags ); - } + $rec = self::getRevisionStore()->getRevisionByPageId( $pageId, $revId, $flags ); + return $rec === null ? null : new Revision( $rec, $flags ); } /** * Make a fake revision object from an archive table row. This is queried * for permissions or even inserted (as in Special:Undelete) - * @todo FIXME: Should be a subclass for RevisionDelete. [TS] * * @param object $row * @param array $overrides @@ -187,68 +146,45 @@ class Revision implements IDBAccessObject { * @return Revision */ public static function newFromArchiveRow( $row, $overrides = [] ) { - global $wgContentHandlerUseDB; - - $attribs = $overrides + [ - 'page' => isset( $row->ar_page_id ) ? $row->ar_page_id : null, - 'id' => isset( $row->ar_rev_id ) ? $row->ar_rev_id : null, - 'comment' => CommentStore::newKey( 'ar_comment' ) - // Legacy because $row may have come from self::selectArchiveFields() - ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text, - 'user' => $row->ar_user, - 'user_text' => $row->ar_user_text, - 'timestamp' => $row->ar_timestamp, - 'minor_edit' => $row->ar_minor_edit, - 'text_id' => isset( $row->ar_text_id ) ? $row->ar_text_id : null, - 'deleted' => $row->ar_deleted, - 'len' => $row->ar_len, - 'sha1' => isset( $row->ar_sha1 ) ? $row->ar_sha1 : null, - 'content_model' => isset( $row->ar_content_model ) ? $row->ar_content_model : null, - 'content_format' => isset( $row->ar_content_format ) ? $row->ar_content_format : null, - ]; - - if ( !$wgContentHandlerUseDB ) { - unset( $attribs['content_model'] ); - unset( $attribs['content_format'] ); - } - - if ( !isset( $attribs['title'] ) - && isset( $row->ar_namespace ) - && isset( $row->ar_title ) - ) { - $attribs['title'] = Title::makeTitle( $row->ar_namespace, $row->ar_title ); - } - - if ( isset( $row->ar_text ) && !$row->ar_text_id ) { - // Pre-1.5 ar_text row - $attribs['text'] = self::getRevisionText( $row, 'ar_' ); - if ( $attribs['text'] === false ) { - throw new MWException( 'Unable to load text from archive row (possibly T24624)' ); - } - } - return new self( $attribs ); + $rec = self::getRevisionStore()->newRevisionFromArchiveRow( $row, 0, null, $overrides ); + return new Revision( $rec ); } /** * @since 1.19 * - * @param object $row + * MCR migration note: replaced by RevisionStore::newRevisionFromRow(). Note that + * newFromRow() also accepts arrays, while newRevisionFromRow() does not. Instead, + * a MutableRevisionRecord should be constructed directly. RevisionStore::newRevisionFromArray() + * can be used as a temporary replacement, but should be avoided. + * + * @param object|array $row * @return Revision */ public static function newFromRow( $row ) { - return new self( $row ); + if ( is_array( $row ) ) { + $rec = self::getRevisionStore()->newMutableRevisionFromArray( $row ); + } else { + $rec = self::getRevisionStore()->newRevisionFromRow( $row ); + } + + return new Revision( $rec ); } /** * Load a page revision from a given revision ID number. * Returns null if no such revision can be found. * + * @deprecated since 1.31, use RevisionStore::getRevisionById() instead. + * * @param IDatabase $db * @param int $id * @return Revision|null */ public static function loadFromId( $db, $id ) { - return self::loadFromConds( $db, [ 'rev_id' => intval( $id ) ] ); + wfDeprecated( __METHOD__, '1.31' ); // no known callers + $rec = self::getRevisionStore()->loadRevisionFromId( $db, $id ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -256,19 +192,16 @@ class Revision implements IDBAccessObject { * that's attached to a given page. If not attached * to that page, will return null. * + * @deprecated since 1.31, use RevisionStore::getRevisionByPageId() instead. + * * @param IDatabase $db * @param int $pageid * @param int $id * @return Revision|null */ public static function loadFromPageId( $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 self::loadFromConds( $db, $conds ); + $rec = self::getRevisionStore()->loadRevisionFromPageId( $db, $pageid, $id ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -276,24 +209,16 @@ class Revision implements IDBAccessObject { * that's attached to a given page. If not attached * to that page, will return null. * + * @deprecated since 1.31, use RevisionStore::getRevisionByTitle() instead. + * * @param IDatabase $db * @param Title $title * @param int $id * @return Revision|null */ public static function loadFromTitle( $db, $title, $id = 0 ) { - if ( $id ) { - $matchId = intval( $id ); - } else { - $matchId = 'page_latest'; - } - return self::loadFromConds( $db, - [ - "rev_id=$matchId", - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ] - ); + $rec = self::getRevisionStore()->loadRevisionFromTitle( $db, $title, $id ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -301,73 +226,17 @@ class Revision implements IDBAccessObject { * WARNING: Timestamps may in some circumstances not be unique, * so this isn't the best key to use. * + * @deprecated since 1.31, use RevisionStore::loadRevisionFromTimestamp() instead. + * * @param IDatabase $db * @param Title $title * @param string $timestamp * @return Revision|null */ public static function loadFromTimestamp( $db, $title, $timestamp ) { - return self::loadFromConds( $db, - [ - 'rev_timestamp' => $db->timestamp( $timestamp ), - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ] - ); - } - - /** - * Given a set of conditions, fetch a revision - * - * This method is used then a revision ID is qualified and - * will incorporate some basic replica DB/master fallback logic - * - * @param array $conditions - * @param int $flags (optional) - * @return Revision|null - */ - private static function newFromConds( $conditions, $flags = 0 ) { - $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); - - $rev = self::loadFromConds( $db, $conditions, $flags ); - // 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 ) - && wfGetLB()->getServerCount() > 1 - && wfGetLB()->hasOrMadeRecentMasterChanges() - ) { - $flags = self::READ_LATEST; - $db = wfGetDB( DB_MASTER ); - $rev = self::loadFromConds( $db, $conditions, $flags ); - } - - if ( $rev ) { - $rev->mQueryFlags = $flags; - } - - return $rev; - } - - /** - * Given a set of conditions, fetch a revision from - * the given database connection. - * - * @param IDatabase $db - * @param array $conditions - * @param int $flags (optional) - * @return Revision|null - */ - private static function loadFromConds( $db, $conditions, $flags = 0 ) { - $row = self::fetchFromConds( $db, $conditions, $flags ); - if ( $row ) { - $rev = new Revision( $row ); - $rev->mWiki = $db->getDomainID(); - - return $rev; - } - - return null; + // XXX: replace loadRevisionFromTimestamp by getRevisionByTimestamp? + $rec = self::getRevisionStore()->loadRevisionFromTimestamp( $db, $title, $timestamp ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -377,52 +246,18 @@ class Revision implements IDBAccessObject { * * @param LinkTarget $title * @return ResultWrapper - * @deprecated Since 1.28 + * @deprecated Since 1.28, no callers in core nor in known extensions. No-op since 1.31. */ public static function fetchRevision( LinkTarget $title ) { - $row = self::fetchFromConds( - wfGetDB( DB_REPLICA ), - [ - 'rev_id=page_latest', - 'page_namespace' => $title->getNamespace(), - 'page_title' => $title->getDBkey() - ] - ); - - return new FakeResultWrapper( $row ? [ $row ] : [] ); - } - - /** - * Given a set of conditions, return a ResultWrapper - * which will return matching database rows with the - * fields necessary to build Revision objects. - * - * @param IDatabase $db - * @param array $conditions - * @param int $flags (optional) - * @return stdClass - */ - private static function fetchFromConds( $db, $conditions, $flags = 0 ) { - $revQuery = self::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'] - ); + wfDeprecated( __METHOD__, '1.31' ); + return new FakeResultWrapper( [] ); } /** * Return the value of a select() JOIN conds array for the user table. * This will get user table rows for logged-in users. * @since 1.19 - * @deprecated since 1.31, use self::getQueryInfo( [ 'user' ] ) instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'user' ] ) instead. * @return array */ public static function userJoinCond() { @@ -434,7 +269,7 @@ class Revision implements IDBAccessObject { * Return the value of a select() page conds array for the page table. * This will assure that the revision(s) are not orphaned from live pages. * @since 1.19 - * @deprecated since 1.31, use self::getQueryInfo( [ 'page' ] ) instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'page' ] ) instead. * @return array */ public static function pageJoinCond() { @@ -445,7 +280,7 @@ class Revision implements IDBAccessObject { /** * Return the list of revision fields that should be selected to create * a new revision. - * @deprecated since 1.31, use self::getQueryInfo() instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo() instead. * @return array */ public static function selectFields() { @@ -480,7 +315,7 @@ class Revision implements IDBAccessObject { /** * Return the list of revision fields that should be selected to create * a new revision from an archive row. - * @deprecated since 1.31, use self::getArchiveQueryInfo() instead. + * @deprecated since 1.31, use RevisionStore::getArchiveQueryInfo() instead. * @return array */ public static function selectArchiveFields() { @@ -516,7 +351,7 @@ class Revision implements IDBAccessObject { /** * Return the list of text fields that should be selected to read the * revision text - * @deprecated since 1.31, use self::getQueryInfo( [ 'text' ] ) instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'text' ] ) instead. * @return array */ public static function selectTextFields() { @@ -529,7 +364,7 @@ class Revision implements IDBAccessObject { /** * Return the list of page fields that should be selected from page table - * @deprecated since 1.31, use self::getQueryInfo( [ 'page' ] ) instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'page' ] ) instead. * @return array */ public static function selectPageFields() { @@ -546,7 +381,7 @@ class Revision implements IDBAccessObject { /** * Return the list of user fields that should be selected from user table - * @deprecated since 1.31, use self::getQueryInfo( [ 'user' ] ) instead. + * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'user' ] ) instead. * @return array */ public static function selectUserFields() { @@ -558,6 +393,7 @@ class Revision implements IDBAccessObject { * Return the tables, fields, and join conditions to be selected to create * a new revision object. * @since 1.31 + * @deprecated since 1.31, use RevisionStore::getQueryInfo() instead. * @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 @@ -568,104 +404,21 @@ class Revision implements IDBAccessObject { * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` */ public static function getQueryInfo( $options = [] ) { - global $wgContentHandlerUseDB; - - $commentQuery = CommentStore::newKey( 'rev_comment' )->getJoin(); - $ret = [ - 'tables' => [ 'revision' ] + $commentQuery['tables'], - 'fields' => [ - 'rev_id', - 'rev_page', - 'rev_text_id', - 'rev_timestamp', - 'rev_user_text', - 'rev_user', - 'rev_minor_edit', - 'rev_deleted', - 'rev_len', - 'rev_parent_id', - 'rev_sha1', - ] + $commentQuery['fields'], - 'joins' => $commentQuery['joins'], - ]; - - if ( $wgContentHandlerUseDB ) { - $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', - ] ); - $ret['joins']['user'] = [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ]; - } - - if ( in_array( 'text', $options, true ) ) { - $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 self::getRevisionStore()->getQueryInfo( $options ); } /** * Return the tables, fields, and join conditions to be selected to create * a new archived revision object. * @since 1.31 + * @deprecated since 1.31, use RevisionStore::getArchiveQueryInfo() instead. * @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 static function getArchiveQueryInfo() { - global $wgContentHandlerUseDB; - - $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin(); - $ret = [ - 'tables' => [ 'archive' ] + $commentQuery['tables'], - 'fields' => [ - 'ar_id', - 'ar_page_id', - 'ar_rev_id', - 'ar_text', - 'ar_text_id', - 'ar_timestamp', - 'ar_user_text', - 'ar_user', - 'ar_minor_edit', - 'ar_deleted', - 'ar_len', - 'ar_parent_id', - 'ar_sha1', - ] + $commentQuery['fields'], - 'joins' => $commentQuery['joins'], - ]; - - if ( $wgContentHandlerUseDB ) { - $ret['fields'][] = 'ar_content_format'; - $ret['fields'][] = 'ar_content_model'; - } - - return $ret; + return self::getRevisionStore()->getArchiveQueryInfo(); } /** @@ -675,203 +428,49 @@ class Revision implements IDBAccessObject { * @return array */ public static function getParentLengths( $db, array $revIds ) { - $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] = $row->rev_len; - } - return $revLens; + return self::getRevisionStore()->listRevisionSizes( $db, $revIds ); } /** - * @param object|array $row Either a database row or an array - * @throws MWException + * @param object|array|RevisionRecord $row Either a database row or an array + * @param int $queryFlags + * @param Title|null $title + * * @access private */ - public function __construct( $row ) { - if ( is_object( $row ) ) { - $this->constructFromDbRowObject( $row ); - } elseif ( is_array( $row ) ) { - $this->constructFromRowArray( $row ); - } else { - throw new MWException( 'Revision constructor passed invalid row format.' ); - } - $this->mUnpatrolled = null; - } + function __construct( $row, $queryFlags = 0, Title $title = null ) { + global $wgUser; - /** - * @param object $row - */ - private function constructFromDbRowObject( $row ) { - $this->mId = intval( $row->rev_id ); - $this->mPage = intval( $row->rev_page ); - $this->mTextId = intval( $row->rev_text_id ); - $this->mComment = CommentStore::newKey( 'rev_comment' ) - // Legacy because $row may have come from self::selectFields() - ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text; - $this->mUser = intval( $row->rev_user ); - $this->mMinorEdit = intval( $row->rev_minor_edit ); - $this->mTimestamp = $row->rev_timestamp; - $this->mDeleted = intval( $row->rev_deleted ); - - if ( !isset( $row->rev_parent_id ) ) { - $this->mParentId = null; - } else { - $this->mParentId = intval( $row->rev_parent_id ); - } - - if ( !isset( $row->rev_len ) ) { - $this->mSize = null; - } else { - $this->mSize = intval( $row->rev_len ); - } - - if ( !isset( $row->rev_sha1 ) ) { - $this->mSha1 = null; - } else { - $this->mSha1 = $row->rev_sha1; - } - - if ( isset( $row->page_latest ) ) { - $this->mCurrent = ( $row->rev_id == $row->page_latest ); - $this->mTitle = Title::newFromRow( $row ); - } else { - $this->mCurrent = false; - $this->mTitle = null; - } - - if ( !isset( $row->rev_content_model ) ) { - $this->mContentModel = null; # determine on demand if needed - } else { - $this->mContentModel = strval( $row->rev_content_model ); - } - - if ( !isset( $row->rev_content_format ) ) { - $this->mContentFormat = null; # determine on demand if needed - } else { - $this->mContentFormat = strval( $row->rev_content_format ); - } + if ( $row instanceof RevisionRecord ) { + $this->mRecord = $row; + } elseif ( is_array( $row ) ) { + if ( !isset( $row['user'] ) && !isset( $row['user_text'] ) ) { + $row['user'] = $wgUser; + } - // Lazy extraction... - $this->mText = null; - if ( isset( $row->old_text ) ) { - $this->mTextRow = $row; + $this->mRecord = self::getRevisionStore()->newMutableRevisionFromArray( + $row, + $queryFlags, + $title + ); + } elseif ( is_object( $row ) ) { + $this->mRecord = self::getRevisionStore()->newRevisionFromRow( + $row, + $queryFlags, + $title + ); } else { - // 'text' table row entry will be lazy-loaded - $this->mTextRow = null; - } - - // Use user_name for users and rev_user_text for IPs... - $this->mUserText = null; // lazy load if left null - if ( $this->mUser == 0 ) { - $this->mUserText = $row->rev_user_text; // IP user - } elseif ( isset( $row->user_name ) ) { - $this->mUserText = $row->user_name; // logged-in user + throw new InvalidArgumentException( + '$row must be a row object, an associative array, or a RevisionRecord' + ); } - $this->mOrigUserText = $row->rev_user_text; } /** - * @param array $row - * - * @throws MWException + * @return RevisionRecord */ - private function constructFromRowArray( array $row ) { - // Build a new revision to be saved... - global $wgUser; // ugh - - # if we have a content object, use it to set the model and type - if ( !empty( $row['content'] ) ) { - if ( !( $row['content'] instanceof Content ) ) { - throw new MWException( '`content` field must contain a Content object.' ); - } - - // @todo when is that set? test with external store setup! check out insertOn() [dk] - if ( !empty( $row['text_id'] ) ) { - throw new MWException( "Text already stored in external store (id {$row['text_id']}), " . - "can't serialize content object" ); - } - - $row['content_model'] = $row['content']->getModel(); - # note: mContentFormat is initializes later accordingly - # note: content is serialized later in this method! - # also set text to null? - } - - $this->mId = isset( $row['id'] ) ? intval( $row['id'] ) : null; - $this->mPage = isset( $row['page'] ) ? intval( $row['page'] ) : null; - $this->mTextId = isset( $row['text_id'] ) ? intval( $row['text_id'] ) : null; - $this->mUserText = isset( $row['user_text'] ) - ? strval( $row['user_text'] ) : $wgUser->getName(); - $this->mUser = isset( $row['user'] ) ? intval( $row['user'] ) : $wgUser->getId(); - $this->mMinorEdit = isset( $row['minor_edit'] ) ? intval( $row['minor_edit'] ) : 0; - $this->mTimestamp = isset( $row['timestamp'] ) - ? strval( $row['timestamp'] ) : wfTimestampNow(); - $this->mDeleted = isset( $row['deleted'] ) ? intval( $row['deleted'] ) : 0; - $this->mSize = isset( $row['len'] ) ? intval( $row['len'] ) : null; - $this->mParentId = isset( $row['parent_id'] ) ? intval( $row['parent_id'] ) : null; - $this->mSha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; - - $this->mContentModel = isset( $row['content_model'] ) - ? strval( $row['content_model'] ) : null; - $this->mContentFormat = isset( $row['content_format'] ) - ? strval( $row['content_format'] ) : null; - - // Enforce spacing trimming on supplied text - $this->mComment = isset( $row['comment'] ) ? trim( strval( $row['comment'] ) ) : null; - $this->mText = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null; - $this->mTextRow = null; - - $this->mTitle = isset( $row['title'] ) ? $row['title'] : null; - - // if we have a Content object, override mText and mContentModel - if ( !empty( $row['content'] ) ) { - $handler = $this->getContentHandler(); - $this->mContent = $row['content']; - - $this->mContentModel = $this->mContent->getModel(); - $this->mContentHandler = null; - - $this->mText = $handler->serializeContent( $row['content'], $this->getContentFormat() ); - } elseif ( $this->mText !== null ) { - $handler = $this->getContentHandler(); - $this->mContent = $handler->unserializeContent( $this->mText ); - } - - // If we have a Title object, make sure it is consistent with mPage. - if ( $this->mTitle && $this->mTitle->exists() ) { - if ( $this->mPage === null ) { - // if the page ID wasn't known, set it now - $this->mPage = $this->mTitle->getArticleID(); - } elseif ( $this->mTitle->getArticleID() !== $this->mPage ) { - // Got different page IDs. This may be legit (e.g. during undeletion), - // but it seems worth mentioning it in the log. - wfDebug( "Page ID " . $this->mPage . " mismatches the ID " . - $this->mTitle->getArticleID() . " provided by the Title object." ); - } - } - - $this->mCurrent = false; - - // If we still have no length, see it we have the text to figure it out - if ( !$this->mSize && $this->mContent !== null ) { - $this->mSize = $this->mContent->getSize(); - } - - // Same for sha1 - if ( $this->mSha1 === null ) { - $this->mSha1 = $this->mText === null ? null : self::base36Sha1( $this->mText ); - } - - // force lazy init - $this->getContentModel(); - $this->getContentFormat(); + public function getRevisionRecord() { + return $this->mRecord; } /** @@ -880,19 +479,27 @@ class Revision implements IDBAccessObject { * @return int|null */ public function getId() { - return $this->mId; + return $this->mRecord->getId(); } /** * Set the revision ID * - * This should only be used for proposed revisions that turn out to be null edits + * This should only be used for proposed revisions that turn out to be null edits. + * + * @note Only supported on Revisions that were constructed based on associative arrays, + * since they are mutable. * * @since 1.19 - * @param int $id + * @param int|string $id + * @throws MWException */ public function setId( $id ) { - $this->mId = (int)$id; + if ( $this->mRecord instanceof MutableRevisionRecord ) { + $this->mRecord->setId( intval( $id ) ); + } else { + throw new MWException( __METHOD__ . ' is not supported on this instance' ); + } } /** @@ -900,106 +507,107 @@ class Revision implements IDBAccessObject { * * This should only be used for proposed revisions that turn out to be null edits * + * @note Only supported on Revisions that were constructed based on associative arrays, + * since they are mutable. + * * @since 1.28 * @deprecated since 1.31, please reuse old Revision object * @param int $id User ID * @param string $name User name + * @throws MWException */ public function setUserIdAndName( $id, $name ) { - $this->mUser = (int)$id; - $this->mUserText = $name; - $this->mOrigUserText = $name; + if ( $this->mRecord instanceof MutableRevisionRecord ) { + $user = new UserIdentityValue( intval( $id ), $name ); + $this->mRecord->setUser( $user ); + } else { + throw new MWException( __METHOD__ . ' is not supported on this instance' ); + } } /** - * Get text row ID + * @return SlotRecord + */ + private function getMainSlotRaw() { + return $this->mRecord->getSlot( 'main', RevisionRecord::RAW ); + } + + /** + * Get the ID of the row of the text table that contains the content of the + * revision's main slot, if that content is stored in the text table. + * + * If the content is stored elsewhere, this returns null. + * + * @deprecated since 1.31, use RevisionRecord()->getSlot()->getContentAddress() to + * get that actual address that can be used with BlobStore::getBlob(); or use + * RevisionRecord::hasSameContent() to check if two revisions have the same content. * * @return int|null */ public function getTextId() { - return $this->mTextId; + $slot = $this->getMainSlotRaw(); + return $slot->hasAddress() + ? self::getBlobStore()->getTextIdFromAddress( $slot->getAddress() ) + : null; } /** * Get parent revision ID (the original previous page revision) * - * @return int|null + * @return int|null The ID of the parent revision. 0 indicates that there is no + * parent revision. Null indicates that the parent revision is not known. */ public function getParentId() { - return $this->mParentId; + return $this->mRecord->getParentId(); } /** * Returns the length of the text in this revision, or null if unknown. * - * @return int|null + * @return int */ public function getSize() { - return $this->mSize; + return $this->mRecord->getSize(); } /** - * Returns the base36 sha1 of the text in this revision, or null if unknown. + * Returns the base36 sha1 of the content in this revision, or null if unknown. * - * @return string|null + * @return string */ public function getSha1() { - return $this->mSha1; + // XXX: we may want to drop all the hashing logic, it's not worth the overhead. + return $this->mRecord->getSha1(); } /** - * Returns the title of the page associated with this entry or null. + * Returns the title of the page associated with this entry. + * Since 1.31, this will never return null. * * Will do a query, when title is not set and id is given. * - * @return Title|null + * @return Title */ public function getTitle() { - if ( $this->mTitle !== null ) { - return $this->mTitle; - } - // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. - if ( $this->mId !== null ) { - $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki ); - // @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' => $this->mId ], - __METHOD__, - [], - [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ] - ); - if ( $row ) { - // @TODO: better foreign title handling - $this->mTitle = Title::newFromRow( $row ); - } - } - - if ( $this->mWiki === false || $this->mWiki === wfWikiID() ) { - // Loading by ID is best, though not possible for foreign titles - if ( !$this->mTitle && $this->mPage !== null && $this->mPage > 0 ) { - $this->mTitle = Title::newFromID( $this->mPage ); - } - } - - return $this->mTitle; + $linkTarget = $this->mRecord->getPageAsLinkTarget(); + return Title::newFromLinkTarget( $linkTarget ); } /** * Set the title of the revision * + * @deprecated: since 1.31, this is now a noop. Pass the Title to the constructor instead. + * * @param Title $title */ public function setTitle( $title ) { - $this->mTitle = $title; + if ( !$title->equals( $this->getTitle() ) ) { + throw new InvalidArgumentException( + $title->getPrefixedText() + . ' is not the same as ' + . $this->mRecord->getPageAsLinkTarget()->__toString() + ); + } } /** @@ -1008,7 +616,7 @@ class Revision implements IDBAccessObject { * @return int|null */ public function getPage() { - return $this->mPage; + return $this->mRecord->getPageId(); } /** @@ -1025,13 +633,14 @@ class Revision implements IDBAccessObject { * @return int */ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { - if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) { - return 0; - } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) { - return 0; - } else { - return $this->mUser; + global $wgUser; + + if ( $audience === self::FOR_THIS_USER && !$user ) { + $user = $wgUser; } + + $user = $this->mRecord->getUser( $audience, $user ); + return $user ? $user->getId() : 0; } /** @@ -1059,23 +668,14 @@ class Revision implements IDBAccessObject { * @return string */ public function getUserText( $audience = self::FOR_PUBLIC, User $user = null ) { - $this->loadMutableFields(); + global $wgUser; - if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) { - return ''; - } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) { - return ''; - } else { - if ( $this->mUserText === null ) { - $this->mUserText = User::whoIs( $this->mUser ); // load on demand - if ( $this->mUserText === false ) { - # This shouldn't happen, but it can if the wiki was recovered - # via importing revs and there is no user table entry yet. - $this->mUserText = $this->mOrigUserText; - } - } - return $this->mUserText; + if ( $audience === self::FOR_THIS_USER && !$user ) { + $user = $wgUser; } + + $user = $this->mRecord->getUser( $audience, $user ); + return $user ? $user->getName() : ''; } /** @@ -1103,13 +703,14 @@ class Revision implements IDBAccessObject { * @return string */ function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { - if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_COMMENT ) ) { - return ''; - } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_COMMENT, $user ) ) { - return ''; - } else { - return $this->mComment; + global $wgUser; + + if ( $audience === self::FOR_THIS_USER && !$user ) { + $user = $wgUser; } + + $comment = $this->mRecord->getComment( $audience, $user ); + return $comment === null ? null : $comment->text; } /** @@ -1127,23 +728,14 @@ class Revision implements IDBAccessObject { * @return bool */ public function isMinor() { - return (bool)$this->mMinorEdit; + return $this->mRecord->isMinor(); } /** * @return int Rcid of the unpatrolled row, zero if there isn't one */ public function isUnpatrolled() { - if ( $this->mUnpatrolled !== null ) { - return $this->mUnpatrolled; - } - $rc = $this->getRecentChange(); - if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) { - $this->mUnpatrolled = $rc->getAttribute( 'rc_id' ); - } else { - $this->mUnpatrolled = 0; - } - return $this->mUnpatrolled; + return self::getRevisionStore()->isUnpatrolled( $this->mRecord ); } /** @@ -1156,19 +748,7 @@ class Revision implements IDBAccessObject { * @return RecentChange|null */ public function getRecentChange( $flags = 0 ) { - $dbr = wfGetDB( DB_REPLICA ); - - list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); - - return RecentChange::newFromConds( - [ - 'rc_user_text' => $this->getUserText( self::RAW ), - 'rc_timestamp' => $dbr->timestamp( $this->getTimestamp() ), - 'rc_this_oldid' => $this->getId() - ], - __METHOD__, - $dbType - ); + return self::getRevisionStore()->getRecentChange( $this->mRecord, $flags ); } /** @@ -1177,14 +757,7 @@ class Revision implements IDBAccessObject { * @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 ( $this->getVisibility() & $field ) == $field; + return $this->mRecord->isDeleted( $field ); } /** @@ -1193,19 +766,17 @@ class Revision implements IDBAccessObject { * @return int */ public function getVisibility() { - $this->loadMutableFields(); - - return (int)$this->mDeleted; + return $this->mRecord->getVisibility(); } /** * Fetch revision content if it's available to the specified audience. * If the specified audience does not have the ability to view this - * revision, null will be returned. + * revision, or the content could not be loaded, null will be returned. * * @param int $audience One of: * Revision::FOR_PUBLIC to be displayed to all users - * Revision::FOR_THIS_USER to be displayed to $wgUser + * Revision::FOR_THIS_USER to be displayed to $user * Revision::RAW get the text regardless of permissions * @param User $user User object to check for, only if FOR_THIS_USER is passed * to the $audience parameter @@ -1213,12 +784,17 @@ class Revision implements IDBAccessObject { * @return Content|null */ public function getContent( $audience = self::FOR_PUBLIC, User $user = null ) { - if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_TEXT ) ) { - return null; - } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_TEXT, $user ) ) { + global $wgUser; + + if ( $audience === self::FOR_THIS_USER && !$user ) { + $user = $wgUser; + } + + try { + return $this->mRecord->getContent( 'main', $audience, $user ); + } + catch ( RevisionAccessException $e ) { return null; - } else { - return $this->getContentInternal(); } } @@ -1226,86 +802,51 @@ class Revision implements IDBAccessObject { * Get original serialized data (without checking view restrictions) * * @since 1.21 + * @deprecated since 1.31, use BlobStore::getBlob instead. + * * @return string */ public function getSerializedData() { - if ( $this->mText === null ) { - // Revision is immutable. Load on demand. - $this->mText = $this->loadText(); - } - - return $this->mText; + $slot = $this->getMainSlotRaw(); + return $slot->getContent()->serialize(); } /** - * Gets the content object for the revision (or null on failure). - * - * Note that for mutable Content objects, each call to this method will return a - * fresh clone. - * - * @since 1.21 - * @return Content|null The Revision's content, or null on failure. - */ - protected function getContentInternal() { - if ( $this->mContent === null ) { - $text = $this->getSerializedData(); - - if ( $text !== null && $text !== false ) { - // Unserialize content - $handler = $this->getContentHandler(); - $format = $this->getContentFormat(); - - $this->mContent = $handler->unserializeContent( $text, $format ); - } - } - - // NOTE: copy() will return $this for immutable content objects - return $this->mContent ? $this->mContent->copy() : null; - } - - /** - * Returns the content model for this revision. + * Returns the content model for the main slot of this revision. * * If no content model was stored in the database, the default content model for the title is * used to determine the content model to use. If no title is know, CONTENT_MODEL_WIKITEXT * is used as a last resort. * + * @todo: drop this, with MCR, there no longer is a single model associated with a revision. + * * @return string The content model id associated with this revision, * see the CONTENT_MODEL_XXX constants. */ public function getContentModel() { - if ( !$this->mContentModel ) { - $title = $this->getTitle(); - if ( $title ) { - $this->mContentModel = ContentHandler::getDefaultModelFor( $title ); - } else { - $this->mContentModel = CONTENT_MODEL_WIKITEXT; - } - - assert( !empty( $this->mContentModel ) ); - } - - return $this->mContentModel; + return $this->getMainSlotRaw()->getModel(); } /** - * Returns the content format for this revision. + * Returns the content format for the main slot of this revision. * * If no content format was stored in the database, the default format for this * revision's content model is returned. * + * @todo: drop this, the format is irrelevant to the revision! + * * @return string The content format id associated with this revision, * see the CONTENT_FORMAT_XXX constants. */ public function getContentFormat() { - if ( !$this->mContentFormat ) { - $handler = $this->getContentHandler(); - $this->mContentFormat = $handler->getDefaultFormat(); + $format = $this->getMainSlotRaw()->getFormat(); - assert( !empty( $this->mContentFormat ) ); + if ( $format === null ) { + // if no format was stored along with the blob, fall back to default format + $format = $this->getContentHandler()->getDefaultFormat(); } - return $this->mContentFormat; + return $format; } /** @@ -1315,33 +856,21 @@ class Revision implements IDBAccessObject { * @return ContentHandler */ public function getContentHandler() { - if ( !$this->mContentHandler ) { - $model = $this->getContentModel(); - $this->mContentHandler = ContentHandler::getForModelID( $model ); - - $format = $this->getContentFormat(); - - if ( !$this->mContentHandler->isSupportedFormat( $format ) ) { - throw new MWException( "Oops, the content format $format is not supported for " - . "this content model, $model" ); - } - } - - return $this->mContentHandler; + return ContentHandler::getForModelID( $this->getContentModel() ); } /** * @return string */ public function getTimestamp() { - return wfTimestamp( TS_MW, $this->mTimestamp ); + return $this->mRecord->getTimestamp(); } /** * @return bool */ public function isCurrent() { - return $this->mCurrent; + return ( $this->mRecord instanceof RevisionStoreRecord ) && $this->mRecord->isCurrent(); } /** @@ -1350,13 +879,8 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public function getPrevious() { - if ( $this->getTitle() ) { - $prev = $this->getTitle()->getPreviousRevisionID( $this->getId() ); - if ( $prev ) { - return self::newFromTitle( $this->getTitle(), $prev ); - } - } - return null; + $rec = self::getRevisionStore()->getPreviousRevision( $this->mRecord ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -1365,38 +889,8 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public function getNext() { - if ( $this->getTitle() ) { - $next = $this->getTitle()->getNextRevisionID( $this->getId() ); - if ( $next ) { - return self::newFromTitle( $this->getTitle(), $next ); - } - } - return null; - } - - /** - * Get previous revision Id for this page_id - * This is used to populate rev_parent_id on save - * - * @param IDatabase $db - * @return int - */ - private function getPreviousRevisionId( $db ) { - if ( $this->mPage === null ) { - return 0; - } - # Use page_latest if ID is not given - if ( !$this->mId ) { - $prevId = $db->selectField( 'page', 'page_latest', - [ 'page_id' => $this->mPage ], - __METHOD__ ); - } else { - $prevId = $db->selectField( 'revision', 'rev_id', - [ 'rev_page' => $this->mPage, 'rev_id < ' . $this->mId ], - __METHOD__, - [ 'ORDER BY' => 'rev_id DESC' ] ); - } - return intval( $prevId ); + $rec = self::getRevisionStore()->getNextRevision( $this->mRecord ); + return $rec === null ? null : new Revision( $rec ); } /** @@ -1429,35 +923,9 @@ class Revision implements IDBAccessObject { return false; } - // Use external methods for external objects, text in table is URL-only then - if ( in_array( 'external', $flags ) ) { - $url = $text; - $parts = explode( '://', $url, 2 ); - if ( count( $parts ) == 1 || $parts[1] == '' ) { - return false; - } - - if ( isset( $row->old_id ) && $wiki === false ) { - // Make use of the wiki-local revision text cache - $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - // The cached value should be decompressed, so handle that and return here - return $cache->getWithSetCallback( - $cache->makeKey( 'revisiontext', 'textid', $row->old_id ), - self::getCacheTTL( $cache ), - function () use ( $url, $wiki, $flags ) { - // No negative caching per Revision::loadText() - $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] ); - - return self::decompressRevisionText( $text, $flags ); - }, - [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ] - ); - } else { - $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] ); - } - } + $cacheKey = isset( $row->old_id ) ? ( 'tt:' . $row->old_id ) : null; - return self::decompressRevisionText( $text, $flags ); + return self::getBlobStore()->expandBlob( $text, $flags, $cacheKey ); } /** @@ -1471,28 +939,7 @@ class Revision implements IDBAccessObject { * @return string */ public static function compressRevisionText( &$text ) { - global $wgCompressRevisions; - $flags = []; - - # Revisions not marked this way will be converted - # on load if $wgLegacyCharset is set in the future. - $flags[] = 'utf-8'; - - if ( $wgCompressRevisions ) { - if ( function_exists( 'gzdeflate' ) ) { - $deflated = gzdeflate( $text ); - - if ( $deflated === false ) { - wfLogWarning( __METHOD__ . ': gzdeflate() failed' ); - } else { - $text = $deflated; - $flags[] = 'gzip'; - } - } else { - wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" ); - } - } - return implode( ',', $flags ); + return self::getBlobStore()->compressData( $text ); } /** @@ -1503,46 +950,7 @@ class Revision implements IDBAccessObject { * @return string|bool Decompressed text, or false on failure */ public static function decompressRevisionText( $text, $flags ) { - global $wgLegacyEncoding, $wgContLang; - - if ( $text === false ) { - // Text failed to be fetched; nothing to do - return false; - } - - if ( in_array( 'gzip', $flags ) ) { - # Deal with optional compression of archived pages. - # This can be done periodically via maintenance/compressOld.php, and - # as pages are saved if $wgCompressRevisions is set. - $text = gzinflate( $text ); - - if ( $text === false ) { - wfLogWarning( __METHOD__ . ': gzinflate() failed' ); - return false; - } - } - - if ( in_array( 'object', $flags ) ) { - # Generic compressed storage - $obj = unserialize( $text ); - if ( !is_object( $obj ) ) { - // Invalid object - return false; - } - $text = $obj->getText(); - } - - if ( $text !== false && $wgLegacyEncoding - && !in_array( 'utf-8', $flags ) && !in_array( 'utf8', $flags ) - ) { - # Old revisions kept around in a legacy encoding? - # Upconvert on demand. - # ("utf8" checked for compatibility with some broken - # conversion scripts 2008-12-30) - $text = $wgContLang->iconv( $wgLegacyEncoding, 'UTF-8', $text ); - } - - return $text; + return self::getBlobStore()->decompressData( $text, $flags ); } /** @@ -1554,192 +962,27 @@ class Revision implements IDBAccessObject { * @return int The revision ID */ public function insertOn( $dbw ) { - global $wgDefaultExternalStore, $wgContentHandlerUseDB; - - // We're inserting a new revision, so we have to use master anyway. - // If it's a null revision, it may have references to rows that - // are not in the replica yet (the text row). - $this->mQueryFlags |= self::READ_LATEST; - - // Not allowed to have rev_page equal to 0, false, etc. - if ( !$this->mPage ) { - $title = $this->getTitle(); - if ( $title instanceof Title ) { - $titleText = ' for page ' . $title->getPrefixedText(); - } else { - $titleText = ''; - } - throw new MWException( "Cannot insert revision$titleText: page ID must be nonzero" ); - } + global $wgUser; - $this->checkContentModel(); + // Note that $this->mRecord->getId() will typically return null here, but not always, + // e.g. not when restoring a revision. - $data = $this->mText; - $flags = self::compressRevisionText( $data ); - - # Write to external storage if required - if ( $wgDefaultExternalStore ) { - // Store and get the URL - $data = ExternalStore::insertToDefault( $data ); - if ( !$data ) { - throw new MWException( "Unable to store text to external storage" ); - } - if ( $flags ) { - $flags .= ','; - } - $flags .= 'external'; - } - - # Record the text (or external storage URL) to the text table - if ( $this->mTextId === null ) { - $dbw->insert( 'text', - [ - 'old_text' => $data, - 'old_flags' => $flags, - ], __METHOD__ - ); - $this->mTextId = $dbw->insertId(); - } - - if ( $this->mComment === null ) { - $this->mComment = ""; - } - - # Record the edit in revisions - $row = [ - 'rev_page' => $this->mPage, - 'rev_text_id' => $this->mTextId, - 'rev_minor_edit' => $this->mMinorEdit ? 1 : 0, - 'rev_user' => $this->mUser, - 'rev_user_text' => $this->mUserText, - 'rev_timestamp' => $dbw->timestamp( $this->mTimestamp ), - 'rev_deleted' => $this->mDeleted, - 'rev_len' => $this->mSize, - 'rev_parent_id' => $this->mParentId === null - ? $this->getPreviousRevisionId( $dbw ) - : $this->mParentId, - 'rev_sha1' => $this->mSha1 === null - ? self::base36Sha1( $this->mText ) - : $this->mSha1, - ]; - if ( $this->mId !== null ) { - $row['rev_id'] = $this->mId; - } - - list( $commentFields, $commentCallback ) = - CommentStore::newKey( 'rev_comment' )->insertWithTempTable( $dbw, $this->mComment ); - $row += $commentFields; - - if ( $wgContentHandlerUseDB ) { - // NOTE: Store null for the default model and format, to save space. - // XXX: Makes the DB sensitive to changed defaults. - // Make this behavior optional? Only in miser mode? - - $model = $this->getContentModel(); - $format = $this->getContentFormat(); - - $title = $this->getTitle(); - - if ( $title === null ) { - throw new MWException( "Insufficient information to determine the title of the " - . "revision's page!" ); + if ( $this->mRecord->getUser( RevisionRecord::RAW ) === null ) { + if ( $this->mRecord instanceof MutableRevisionRecord ) { + $this->mRecord->setUser( $wgUser ); + } else { + throw new MWException( 'Cannot insert revision with no associated user.' ); } - - $defaultModel = ContentHandler::getDefaultModelFor( $title ); - $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat(); - - $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model; - $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format; } - $dbw->insert( 'revision', $row, __METHOD__ ); + $rec = self::getRevisionStore()->insertRevisionOn( $this->mRecord, $dbw ); - if ( $this->mId === null ) { - // Only if auto-increment was used - $this->mId = $dbw->insertId(); - } - $commentCallback( $this->mId ); + $this->mRecord = $rec; - // Assertion to try to catch T92046 - if ( (int)$this->mId === 0 ) { - throw new UnexpectedValueException( - 'After insert, Revision mId is ' . var_export( $this->mId, 1 ) . ': ' . - var_export( $row, 1 ) - ); - } + // TODO: hard-deprecate in 1.32 (or even 1.31?) + Hooks::run( 'RevisionInsertComplete', [ $this, null, null ] ); - // Insert IP revision into ip_changes for use when querying for a range. - if ( $this->mUser === 0 && IP::isValid( $this->mUserText ) ) { - $ipcRow = [ - 'ipc_rev_id' => $this->mId, - 'ipc_rev_timestamp' => $row['rev_timestamp'], - 'ipc_hex' => IP::toHex( $row['rev_user_text'] ), - ]; - $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); - } - - // Avoid PHP 7.1 warning of passing $this by reference - $revision = $this; - Hooks::run( 'RevisionInsertComplete', [ &$revision, $data, $flags ] ); - - return $this->mId; - } - - protected function checkContentModel() { - global $wgContentHandlerUseDB; - - // Note: may return null for revisions that have not yet been inserted - $title = $this->getTitle(); - - $model = $this->getContentModel(); - $format = $this->getContentFormat(); - $handler = $this->getContentHandler(); - - if ( !$handler->isSupportedFormat( $format ) ) { - $t = $title->getPrefixedDBkey(); - - throw new MWException( "Can't use format $format with content model $model on $t" ); - } - - if ( !$wgContentHandlerUseDB && $title ) { - // if $wgContentHandlerUseDB is not set, - // all revisions must use the default content model and format. - - $defaultModel = ContentHandler::getDefaultModelFor( $title ); - $defaultHandler = ContentHandler::getForModelID( $defaultModel ); - $defaultFormat = $defaultHandler->getDefaultFormat(); - - if ( $this->getContentModel() != $defaultModel ) { - $t = $title->getPrefixedDBkey(); - - throw new MWException( "Can't save non-default content model with " - . "\$wgContentHandlerUseDB disabled: model is $model, " - . "default for $t is $defaultModel" ); - } - - if ( $this->getContentFormat() != $defaultFormat ) { - $t = $title->getPrefixedDBkey(); - - throw new MWException( "Can't use non-default content format with " - . "\$wgContentHandlerUseDB disabled: format is $format, " - . "default for $t is $defaultFormat" ); - } - } - - $content = $this->getContent( self::RAW ); - $prefixedDBkey = $title->getPrefixedDBkey(); - $revId = $this->mId; - - if ( !$content ) { - throw new MWException( - "Content of revision $revId ($prefixedDBkey) could not be loaded for validation!" - ); - } - if ( !$content->isValid() ) { - throw new MWException( - "Content of revision $revId ($prefixedDBkey) is not valid! Content model is $model" - ); - } + return $rec->getId(); } /** @@ -1748,103 +991,7 @@ class Revision implements IDBAccessObject { * @return string */ public static function base36Sha1( $text ) { - return Wikimedia\base_convert( sha1( $text ), 16, 36, 31 ); - } - - /** - * Get the text cache TTL - * - * @param WANObjectCache $cache - * @return int - */ - private static function getCacheTTL( WANObjectCache $cache ) { - global $wgRevisionCacheExpiry; - - if ( $cache->getQoS( $cache::ATTR_EMULATION ) <= $cache::QOS_EMULATION_SQL ) { - // Do not cache RDBMs blobs in...the RDBMs store - $ttl = $cache::TTL_UNCACHEABLE; - } else { - $ttl = $wgRevisionCacheExpiry ?: $cache::TTL_UNCACHEABLE; - } - - return $ttl; - } - - /** - * Lazy-load the revision's text. - * Currently hardcoded to the 'text' table storage engine. - * - * @return string|bool The revision's text, or false on failure - */ - private function loadText() { - $cache = ObjectCache::getMainWANInstance(); - - // No negative caching; negative hits on text rows may be due to corrupted replica DBs - return $cache->getWithSetCallback( - $cache->makeKey( 'revisiontext', 'textid', $this->getTextId() ), - self::getCacheTTL( $cache ), - function () { - return $this->fetchText(); - }, - [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ] - ); - } - - private function fetchText() { - $textId = $this->getTextId(); - - // If we kept data for lazy extraction, use it now... - if ( $this->mTextRow !== null ) { - $row = $this->mTextRow; - $this->mTextRow = null; - } else { - $row = null; - } - - // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables - // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases. - $flags = $this->mQueryFlags; - $flags |= DBAccessObjectUtils::hasFlags( $flags, self::READ_LATEST ) - ? self::READ_LATEST_IMMUTABLE - : 0; - - list( $index, $options, $fallbackIndex, $fallbackOptions ) = - DBAccessObjectUtils::getDBOptions( $flags ); - - if ( !$row ) { - // Text data is immutable; check replica DBs first. - $row = wfGetDB( $index )->selectRow( - 'text', - [ 'old_text', 'old_flags' ], - [ 'old_id' => $textId ], - __METHOD__, - $options - ); - } - - // Fallback to DB_MASTER in some cases if the row was not found - if ( !$row && $fallbackIndex !== null ) { - // Use FOR UPDATE if it was used to fetch this revision. This avoids missing the row - // due to REPEATABLE-READ. Also fallback to the master if READ_LATEST is provided. - $row = wfGetDB( $fallbackIndex )->selectRow( - 'text', - [ 'old_text', 'old_flags' ], - [ 'old_id' => $textId ], - __METHOD__, - $fallbackOptions - ); - } - - if ( !$row ) { - wfDebugLog( 'Revision', "No text row with ID '$textId' (revision {$this->getId()})." ); - } - - $text = self::getRevisionText( $row ); - if ( $row && $text === false ) { - wfDebugLog( 'Revision', "No blob for text row '$textId' (revision {$this->getId()})." ); - } - - return is_string( $text ) ? $text : false; + return SlotRecord::base36Sha1( $text ); } /** @@ -1863,58 +1010,17 @@ class Revision implements IDBAccessObject { * @return Revision|null Revision or null on error */ public static function newNullRevision( $dbw, $pageId, $summary, $minor, $user = null ) { - global $wgContentHandlerUseDB; - - $fields = [ 'page_latest', 'page_namespace', 'page_title', - 'rev_text_id', 'rev_len', 'rev_sha1' ]; - - if ( $wgContentHandlerUseDB ) { - $fields[] = 'rev_content_model'; - $fields[] = 'rev_content_format'; + global $wgUser; + if ( !$user ) { + $user = $wgUser; } - $current = $dbw->selectRow( - [ 'page', 'revision' ], - $fields, - [ - 'page_id' => $pageId, - 'page_latest=rev_id', - ], - __METHOD__, - [ 'FOR UPDATE' ] // T51581 - ); - - if ( $current ) { - if ( !$user ) { - global $wgUser; - $user = $wgUser; - } - - $row = [ - 'page' => $pageId, - 'user_text' => $user->getName(), - 'user' => $user->getId(), - 'comment' => $summary, - 'minor_edit' => $minor, - 'text_id' => $current->rev_text_id, - 'parent_id' => $current->page_latest, - 'len' => $current->rev_len, - 'sha1' => $current->rev_sha1 - ]; - - if ( $wgContentHandlerUseDB ) { - $row['content_model'] = $current->rev_content_model; - $row['content_format'] = $current->rev_content_format; - } - - $row['title'] = Title::makeTitle( $current->page_namespace, $current->page_title ); + $comment = CommentStoreComment::newUnsavedComment( $summary, null ); - $revision = new Revision( $row ); - } else { - $revision = null; - } + $title = Title::newFromID( $pageId ); + $rec = self::getRevisionStore()->newNullRevision( $dbw, $title, $comment, $minor, $user ); - return $revision; + return new Revision( $rec ); } /** @@ -1948,35 +1054,13 @@ class Revision implements IDBAccessObject { public static function userCanBitfield( $bitfield, $field, User $user = null, Title $title = null ) { - if ( $bitfield & $field ) { // aspect is deleted - if ( $user === null ) { - global $wgUser; - $user = $wgUser; - } - 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 call_user_func_array( [ $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; + global $wgUser; + + if ( !$user ) { + $user = $wgUser; } + + return RevisionRecord::userCanBitfield( $bitfield, $field, $user, $title ); } /** @@ -1988,18 +1072,7 @@ class Revision implements IDBAccessObject { * @return string|bool False if not found */ static function getTimestampFromId( $title, $id, $flags = 0 ) { - $db = ( $flags & self::READ_LATEST ) - ? wfGetDB( DB_MASTER ) - : wfGetDB( DB_REPLICA ); - // Casting fix for databases that can't take '' for rev_id - if ( $id == '' ) { - $id = 0; - } - $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; + return self::getRevisionStore()->getTimestampFromId( $title, $id, $flags ); } /** @@ -2010,12 +1083,7 @@ class Revision implements IDBAccessObject { * @return int */ static function countByPageId( $db, $id ) { - $row = $db->selectRow( 'revision', [ 'revCount' => 'COUNT(*)' ], - [ 'rev_page' => $id ], __METHOD__ ); - if ( $row ) { - return $row->revCount; - } - return 0; + return self::getRevisionStore()->countRevisionsByPageId( $db, $id ); } /** @@ -2026,11 +1094,7 @@ class Revision implements IDBAccessObject { * @return int */ static function countByTitle( $db, $title ) { - $id = $title->getArticleID(); - if ( $id ) { - return self::countByPageId( $db, $id ); - } - return 0; + return self::getRevisionStore()->countRevisionsByTitle( $db, $title ); } /** @@ -2050,28 +1114,11 @@ class Revision implements IDBAccessObject { * @return bool True if the given user was the only one to edit since the given timestamp */ public static function userWasLastToEdit( $db, $pageId, $userId, $since ) { - if ( !$userId ) { - return false; - } - if ( is_int( $db ) ) { $db = wfGetDB( $db ); } - $res = $db->select( 'revision', - 'rev_user', - [ - 'rev_page' => $pageId, - 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) - ], - __METHOD__, - [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] ); - foreach ( $res as $row ) { - if ( $row->rev_user != $userId ) { - return false; - } - } - return true; + return self::getRevisionStore()->userWasLastToEdit( $db, $pageId, $userId, $since ); } /** @@ -2079,54 +1126,20 @@ class Revision implements IDBAccessObject { * * 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. - * The title will also be lazy loaded, though setTitle() can be used to preload it. + * The title will also be loaded if $pageIdOrTitle is an integer ID. * - * @param IDatabase $db - * @param int $pageId Page ID - * @param int $revId Known current revision of this page + * @param IDatabase $db ignored! + * @param int|Title $pageIdOrTitle Page ID or Title object + * @param int $revId Known current revision of this page. Determined automatically if not given. * @return Revision|bool Returns false if missing * @since 1.28 */ - public static function newKnownCurrent( IDatabase $db, $pageId, $revId ) { - $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - return $cache->getWithSetCallback( - // Page/rev IDs passed in from DB to reflect history merges - $cache->makeGlobalKey( 'revision', $db->getDomainID(), $pageId, $revId ), - $cache::TTL_WEEK, - function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) { - $setOpts += Database::getCacheSetOptions( $db ); - - $rev = Revision::loadFromPageId( $db, $pageId, $revId ); - // Reflect revision deletion and user renames - if ( $rev ) { - $rev->mTitle = null; // mutable; lazy-load - $rev->mRefreshMutableFields = true; - } - - return $rev ?: false; // don't cache negatives - } - ); - } - - /** - * For cached revisions, make sure the user name and rev_deleted is up-to-date - */ - private function loadMutableFields() { - if ( !$this->mRefreshMutableFields ) { - return; // not needed - } + public static function newKnownCurrent( IDatabase $db, $pageIdOrTitle, $revId = 0 ) { + $title = $pageIdOrTitle instanceof Title + ? $pageIdOrTitle + : Title::newFromID( $pageIdOrTitle ); - $this->mRefreshMutableFields = false; - $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki ); - $row = $dbr->selectRow( - [ 'revision', 'user' ], - [ 'rev_deleted', 'user_name' ], - [ 'rev_id' => $this->mId, 'user_id = rev_user' ], - __METHOD__ - ); - if ( $row ) { // update values - $this->mDeleted = (int)$row->rev_deleted; - $this->mUserText = $row->user_name; - } + $record = self::getRevisionStore()->getKnownCurrentRevision( $title, $revId ); + return $record ? new Revision( $record ) : false; } } diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index d21bcef332..575970d23f 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -450,6 +450,46 @@ return [ return $factory; }, + 'RevisionStore' => function ( MediaWikiServices $services ) { + /** @var SqlBlobStore $blobStore */ + $blobStore = $services->getService( '_SqlBlobStore' ); + + $store = new RevisionStore( + $services->getDBLoadBalancer(), + $blobStore, + $services->getMainWANObjectCache() + ); + + $config = $services->getMainConfig(); + $store->setContentHandlerUseDB( $config->get( 'ContentHandlerUseDB' ) ); + + return $store; + }, + + 'BlobStore' => function ( MediaWikiServices $services ) { + return $services->getService( '_SqlBlobStore' ); + }, + + '_SqlBlobStore' => function ( MediaWikiServices $services ) { + global $wgContLang; // TODO: manage $wgContLang as a service + + $store = new SqlBlobStore( + $services->getDBLoadBalancer(), + $services->getMainWANObjectCache() + ); + + $config = $services->getMainConfig(); + $store->setCompressRevisions( $config->get( 'CompressRevisions' ) ); + $store->setCacheExpiry( $config->get( 'RevisionCacheExpiry' ) ); + $store->setUseExternalStore( $config->get( 'DefaultExternalStore' ) !== false ); + + if ( $config->get( 'LegacyEncoding' ) ) { + $store->setLegacyEncoding( $config->get( 'LegacyEncoding' ), $wgContLang ); + } + + return $store; + }, + 'ExternalStoreFactory' => function ( MediaWikiServices $services ) { $config = $services->getMainConfig(); diff --git a/includes/actions/HistoryAction.php b/includes/actions/HistoryAction.php index 0e964bf5cc..85e8db63b0 100644 --- a/includes/actions/HistoryAction.php +++ b/includes/actions/HistoryAction.php @@ -335,8 +335,8 @@ class HistoryAction extends FormlessAction { * @return FeedItem */ function feedItem( $row ) { - $rev = new Revision( $row ); - $rev->setTitle( $this->getTitle() ); + $rev = new Revision( $row, 0, $this->getTitle() ); + $text = FeedUtils::formatDiffRow( $this->getTitle(), $this->getTitle()->getPreviousRevisionID( $rev->getId() ), @@ -639,12 +639,10 @@ class HistoryPager extends ReverseChronologicalPager { */ function historyLine( $row, $next, $notificationtimestamp = false, $latest = false, $firstInList = false ) { - $rev = new Revision( $row ); - $rev->setTitle( $this->getTitle() ); + $rev = new Revision( $row, 0, $this->getTitle() ); if ( is_object( $next ) ) { - $prevRev = new Revision( $next ); - $prevRev->setTitle( $this->getTitle() ); + $prevRev = new Revision( $next, 0, $this->getTitle() ); } else { $prevRev = null; } diff --git a/includes/cache/MessageCache.php b/includes/cache/MessageCache.php index 768f980b26..d6e9b748ab 100644 --- a/includes/cache/MessageCache.php +++ b/includes/cache/MessageCache.php @@ -1048,8 +1048,7 @@ class MessageCache { if ( $titleObj->getLatestRevID() ) { $revision = Revision::newKnownCurrent( $dbr, - $titleObj->getArticleID(), - $titleObj->getLatestRevID() + $titleObj ); } else { $revision = false; diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index ac9cd84403..c37566bdf6 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -23,6 +23,7 @@ use MediaWiki\Edit\PreparedEdit; use \MediaWiki\Logger\LoggerFactory; use \MediaWiki\MediaWikiServices; +use Wikimedia\Assert\Assert; use Wikimedia\Rdbms\FakeResultWrapper; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\DBError; @@ -671,7 +672,7 @@ class WikiPage implements Page, IDBAccessObject { $revision = Revision::newFromPageId( $this->getId(), $latest, $flags ); } else { $dbr = wfGetDB( DB_REPLICA ); - $revision = Revision::newKnownCurrent( $dbr, $this->getId(), $latest ); + $revision = Revision::newKnownCurrent( $dbr, $this->getTitle(), $latest ); } if ( $revision ) { // sanity @@ -1264,8 +1265,11 @@ class WikiPage implements Page, IDBAccessObject { $conditions['page_latest'] = $lastRevision; } + $revId = $revision->getId(); + Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' ); + $row = [ /* SET */ - 'page_latest' => $revision->getId(), + 'page_latest' => $revId, 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ), 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, 'page_is_redirect' => $rt !== null ? 1 : 0, diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index 13d8a3aa0e..10a338ed01 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -3498,13 +3498,7 @@ class Parser { * @return Revision|bool False if missing */ public static function statelessFetchRevision( Title $title, $parser = false ) { - $pageId = $title->getArticleID(); - $revId = $title->getLatestRevID(); - - $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $pageId, $revId ); - if ( $rev ) { - $rev->setTitle( $title ); - } + $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); return $rev; } diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index bebc1887dc..6eddfc0923 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -183,12 +183,10 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { * @return Content|null */ protected function getContentObj( Title $title ) { - $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title->getArticleID(), - $title->getLatestRevID() ); + $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); if ( !$revision ) { return null; } - $revision->setTitle( $title ); $content = $revision->getContent( Revision::RAW ); if ( !$content ) { wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' ); diff --git a/includes/specials/SpecialNewpages.php b/includes/specials/SpecialNewpages.php index 671ab6fb55..1639386e3b 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -290,15 +290,16 @@ class SpecialNewpages extends IncludableSpecialPage { /** * @param stdClass $result Result row from recent changes - * @return Revision|bool + * @param Title $title + * @return bool|Revision */ - protected function revisionFromRcResult( stdClass $result ) { + protected function revisionFromRcResult( stdClass $result, Title $title ) { return new Revision( [ 'comment' => CommentStore::newKey( 'rc_comment' )->getComment( $result )->text, 'deleted' => $result->rc_deleted, 'user_text' => $result->rc_user_text, 'user' => $result->rc_user, - ] ); + ], 0, $title ); } /** @@ -313,8 +314,7 @@ class SpecialNewpages extends IncludableSpecialPage { // Revision deletion works on revisions, // so cast our recent change row to a revision row. - $rev = $this->revisionFromRcResult( $result ); - $rev->setTitle( $title ); + $rev = $this->revisionFromRcResult( $result, $title ); $classes = []; $attribs = [ 'data-mw-revid' => $result->rev_id ]; diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php index a5c468806f..6bab16feeb 100644 --- a/tests/phpunit/includes/MediaWikiServicesTest.php +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -7,6 +7,9 @@ use MediaWiki\Services\DestructibleService; use MediaWiki\Services\SalvageableService; use MediaWiki\Services\ServiceDisabledException; use MediaWiki\Shell\CommandFactory; +use MediaWiki\Storage\BlobStore; +use MediaWiki\Storage\RevisionStore; +use MediaWiki\Storage\SqlBlobStore; /** * @covers MediaWiki\MediaWikiServices @@ -331,6 +334,9 @@ class MediaWikiServicesTest extends MediaWikiTestCase { 'LocalServerObjectCache' => [ 'LocalServerObjectCache', BagOStuff::class ], 'VirtualRESTServiceClient' => [ 'VirtualRESTServiceClient', VirtualRESTServiceClient::class ], 'ShellCommandFactory' => [ 'ShellCommandFactory', CommandFactory::class ], + 'BlobStore' => [ 'BlobStore', BlobStore::class ], + '_SqlBlobStore' => [ '_SqlBlobStore', SqlBlobStore::class ], + 'RevisionStore' => [ 'RevisionStore', RevisionStore::class ], ]; } diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index 91dbf2cf70..9ab76c8832 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -1,4 +1,8 @@ resetNamespaces(); + if ( !$this->testPage ) { /** * We have to create a new page for each subclass as the page creation may result @@ -102,6 +107,14 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $props['text'] = 'Lorem Ipsum'; } + if ( !isset( $props['user_text'] ) ) { + $props['user_text'] = 'Tester'; + } + + if ( !isset( $props['user'] ) ) { + $props['user'] = 0; + } + if ( !isset( $props['comment'] ) ) { $props['comment'] = 'just a test'; } @@ -110,6 +123,10 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $props['page'] = $this->testPage->getId(); } + if ( !isset( $props['content_model'] ) ) { + $props['content_model'] = CONTENT_MODEL_WIKITEXT; + } + $rev = new Revision( $props ); $dbw = wfGetDB( DB_MASTER ); @@ -202,14 +219,23 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $revId = $rev->insertOn( wfGetDB( DB_MASTER ) ); $this->assertInternalType( 'integer', $revId ); - $this->assertInternalType( 'integer', $rev->getTextId() ); $this->assertSame( $revId, $rev->getId() ); + // getTextId() must be an int! + $this->assertInternalType( 'integer', $rev->getTextId() ); + + $mainSlot = $rev->getRevisionRecord()->getSlot( 'main', RevisionRecord::RAW ); + + // we currently only support storage in the text table + $textId = MediaWikiServices::getInstance() + ->getBlobStore() + ->getTextIdFromAddress( $mainSlot->getAddress() ); + $this->assertSelect( 'text', [ 'old_id', 'old_text' ], - "old_id = {$rev->getTextId()}", - [ [ strval( $rev->getTextId() ), 'Revision Text' ] ] + "old_id = $textId", + [ [ strval( $textId ), 'Revision Text' ] ] ); $this->assertSelect( 'revision', @@ -228,7 +254,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { [ [ strval( $rev->getId() ), strval( $this->testPage->getId() ), - strval( $rev->getTextId() ), + strval( $textId ), '0', '0', '0', @@ -246,11 +272,12 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { // If an ExternalStore is set don't use it. $this->setMwGlobals( 'wgDefaultExternalStore', false ); $this->setExpectedException( - MWException::class, - "Cannot insert revision: page ID must be nonzero" + IncompleteRevisionException::class, + "rev_page field must not be 0!" ); - $rev = new Revision( [] ); + $title = Title::newFromText( 'Nonexistant-' . __METHOD__ ); + $rev = new Revision( [], 0, $title ); $rev->insertOn( wfGetDB( DB_MASTER ) ); } @@ -321,12 +348,42 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { return $f + [ 'ar_namespace', 'ar_title' ]; }, ]; + yield [ + function ( $f ) { + unset( $f['ar_text'] ); + return $f; + }, + ]; yield [ function ( $f ) { unset( $f['ar_text_id'] ); return $f; }, ]; + yield [ + function ( $f ) { + unset( $f['ar_page_id'] ); + return $f; + }, + ]; + yield [ + function ( $f ) { + unset( $f['ar_parent_id'] ); + return $f; + }, + ]; + yield [ + function ( $f ) { + unset( $f['ar_rev_id'] ); + return $f; + }, + ]; + yield [ + function ( $f ) { + unset( $f['ar_sha1'] ); + return $f; + }, + ]; } /** @@ -334,6 +391,17 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { * @covers Revision::newFromArchiveRow */ public function testNewFromArchiveRow( $selectModifier ) { + $services = MediaWikiServices::getInstance(); + + $store = new RevisionStore( + $services->getDBLoadBalancer(), + $services->getService( '_SqlBlobStore' ), + $services->getMainWANObjectCache() + ); + + $store->setContentHandlerUseDB( $this->getContentHandlerUseDB() ); + $this->setService( 'RevisionStore', $store ); + $page = $this->createPage( 'RevisionStorageTest_testNewFromArchiveRow', 'Lorem Ipsum', @@ -354,6 +422,8 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $row = $res->fetchObject(); $res->free(); + // MCR migration note: $row is now required to contain ar_title and ar_namespace. + // Alternatively, a Title object can be passed to RevisionStore::newRevisionFromArchiveRow $rev = Revision::newFromArchiveRow( $row ); $this->assertRevEquals( $orig, $rev ); @@ -382,7 +452,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $row = $res->fetchObject(); $res->free(); - $rev = Revision::newFromArchiveRow( $row, [ 'comment' => 'SOMEOVERRIDE' ] ); + $rev = Revision::newFromArchiveRow( $row, [ 'comment_text' => 'SOMEOVERRIDE' ] ); $this->assertNotEquals( $orig->getComment(), $rev->getComment() ); $this->assertEquals( 'SOMEOVERRIDE', $rev->getComment() ); @@ -426,7 +496,8 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { * @covers Revision::newFromPageId */ public function testNewFromPageIdWithNotLatestId() { - $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); + $content = new WikitextContent( __METHOD__ ); + $this->testPage->doEditContent( $content, __METHOD__ ); $rev = Revision::newFromPageId( $this->testPage->getId(), $this->testPage->getRevision()->getPrevious()->getId() @@ -447,6 +518,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); $id = $this->testPage->getRevision()->getId(); + $this->hideDeprecated( 'Revision::fetchRevision' ); $res = Revision::fetchRevision( $this->testPage->getTitle() ); # note: order is unspecified @@ -455,8 +527,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rows[$row->rev_id] = $row; } - $this->assertEquals( 1, count( $rows ), 'expected exactly one revision' ); - $this->assertArrayHasKey( $id, $rows, 'missing revision with id ' . $id ); + $this->assertEmpty( $rows, 'expected empty set' ); } /** @@ -541,6 +612,10 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'new null revision should have a different id from the original revision' ); $this->assertEquals( $orig->getTextId(), $rev->getTextId(), 'new null revision should have the same text id as the original revision' ); + $this->assertEquals( $orig->getSha1(), $rev->getSha1(), + 'new null revision should have the same SHA1 as the original revision' ); + $this->assertTrue( $orig->getRevisionRecord()->hasSameContent( $rev->getRevisionRecord() ), + 'new null revision should have the same content as the original revision' ); $this->assertEquals( __METHOD__, $rev->getContent()->getNativeData() ); } @@ -606,7 +681,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'zero', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'summary' => 'edit zero' + 'comment' => 'edit zero' ] ); $revisions[0]->insertOn( $dbw ); @@ -618,7 +693,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'one', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'summary' => 'edit one' + 'comment' => 'edit one' ] ); $revisions[1]->insertOn( $dbw ); @@ -629,7 +704,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userB->getId(), 'text' => 'two', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'summary' => 'edit two' + 'comment' => 'edit two' ] ); $revisions[2]->insertOn( $dbw ); @@ -640,7 +715,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'three', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'summary' => 'edit three' + 'comment' => 'edit three' ] ); $revisions[3]->insertOn( $dbw ); @@ -651,13 +726,24 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'zero', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'summary' => 'edit four' + 'comment' => 'edit four' ] ); $revisions[4]->insertOn( $dbw ); // test it --------------------------------- $since = $revisions[$sinceIdx]->getTimestamp(); + $allRows = iterator_to_array( $dbw->select( + 'revision', + [ 'rev_id', 'rev_timestamp', 'rev_user' ], + [ + 'rev_page' => $page->getId(), + //'rev_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $since ) ) + ], + __METHOD__, + [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] + ) ); + $wasLast = Revision::userWasLastToEdit( $dbw, $page->getId(), $userA->getId(), $since ); $this->assertEquals( $expectedLast, $wasLast ); @@ -805,12 +891,16 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'text_id' => 123456789, // not in the test DB ] ); + MediaWiki\suppressWarnings(); // bad text_id will trigger a warning. + $this->assertNull( $rev->getContent(), "getContent() should return null if the revision's text blob could not be loaded." ); // NOTE: check this twice, once for lazy initialization, and once with the cached value. $this->assertNull( $rev->getContent(), "getContent() should return null if the revision's text blob could not be loaded." ); + + MediaWiki\suppressWarnings( 'end' ); } public function provideGetSize() { @@ -904,6 +994,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { */ public function testLoadFromId() { $rev = $this->testPage->getRevision(); + $this->hideDeprecated( 'Revision::loadFromId' ); $this->assertRevEquals( $rev, Revision::loadFromId( wfGetDB( DB_MASTER ), $rev->getId() ) @@ -1026,7 +1117,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev[1] = $this->testPage->getLatest(); $this->assertSame( - [ $rev[1] => strval( $textLength ) ], + [ $rev[1] => $textLength ], Revision::getParentLengths( wfGetDB( DB_MASTER ), [ $rev[1] ] @@ -1049,7 +1140,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev[2] = $this->testPage->getLatest(); $this->assertSame( - [ $rev[1] => strval( $textOneLength ), $rev[2] => strval( $textTwoLength ) ], + [ $rev[1] => $textOneLength, $rev[2] => $textTwoLength ], Revision::getParentLengths( wfGetDB( DB_MASTER ), [ $rev[1], $rev[2] ] @@ -1080,14 +1171,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { ); } - /** - * @covers Revision::getTitle - */ - public function testGetTitle_forBadRevision() { - $rev = new Revision( [] ); - $this->assertNull( $rev->getTitle() ); - } - /** * @covers Revision::isMinor */ @@ -1263,14 +1346,21 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev = $this->testPage->getRevision(); // Clear any previous cache for the revision during creation - $key = $cache->makeGlobalKey( 'revision', $db->getDomainID(), $rev->getPage(), $rev->getId() ); + $key = $cache->makeGlobalKey( 'revision-row-1.29', + $db->getDomainID(), + $rev->getPage(), + $rev->getId() + ); $cache->delete( $key, WANObjectCache::HOLDOFF_NONE ); $this->assertFalse( $cache->get( $key ) ); // Get the new revision and make sure it is in the cache and correct $newRev = Revision::newKnownCurrent( $db, $rev->getPage(), $rev->getId() ); $this->assertRevEquals( $rev, $newRev ); - $this->assertRevEquals( $rev, $cache->get( $key ) ); + + $cachedRow = $cache->get( $key ); + $this->assertNotFalse( $cachedRow ); + $this->assertEquals( $rev->getId(), $cachedRow->rev_id ); } public function provideUserCanBitfield() { @@ -1377,7 +1467,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { ] ); $user = $this->getTestUser( $userGroups )->getUser(); - $revision = new Revision( [ 'deleted' => $bitField ] ); + $revision = new Revision( [ 'deleted' => $bitField ], 0, $this->testPage->getTitle() ); $this->assertSame( $expected, diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php index e4ea40f9ba..01762b92ef 100644 --- a/tests/phpunit/includes/RevisionTest.php +++ b/tests/phpunit/includes/RevisionTest.php @@ -1,6 +1,9 @@ getMockTitle() ); $this->assertNotNull( $rev->getContent(), 'no content object available' ); $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); @@ -65,7 +68,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers Revision::constructFromRowArray + * @covers RevisionStore::newMutableRevisionFromArray */ public function testConstructFromEmptyArray() { $rev = new Revision( [], 0, $this->getMockTitle() ); @@ -90,30 +93,20 @@ class RevisionTest extends MediaWikiTestCase { 99, 'SomeTextUserName', ]; - // Note: the below XXX test cases are odd and probably result in unexpected behaviour if used - // in production code. - yield 'XXX: user text only' => [ + yield 'user text only' => [ [ 'content' => new JavaScriptContent( 'hello world.' ), 'user_text' => '111.111.111.111', ], - null, + 0, '111.111.111.111', ]; - yield 'XXX: user id only' => [ - [ - 'content' => new JavaScriptContent( 'hello world.' ), - 'user' => 9989, - ], - 9989, - null, - ]; } /** * @dataProvider provideConstructFromArray_userSetAsExpected * @covers Revision::__construct - * @covers Revision::constructFromRowArray + * @covers RevisionStore::newMutableRevisionFromArray * * @param array $rowArray * @param mixed $expectedUserId null to expect the current wgUser ID @@ -133,7 +126,7 @@ class RevisionTest extends MediaWikiTestCase { $expectedUserName = $testUser->getName(); } - $rev = new Revision( $rowArray ); + $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); $this->assertEquals( $expectedUserId, $rev->getUser() ); $this->assertEquals( $expectedUserName, $rev->getUserText() ); } @@ -143,28 +136,37 @@ class RevisionTest extends MediaWikiTestCase { [ 'content' => new WikitextContent( 'GOAT' ), 'text_id' => 'someid', - ], + ], new MWException( "Text already stored in external store (id someid), " . "can't serialize content object" ) ]; + yield 'unknown user id and no user name' => [ + [ + 'content' => new JavaScriptContent( 'hello world.' ), + 'user' => 9989, + ], + new MWException( 'user_text not given, and unknown user ID 9989' ) + ]; yield 'with bad content object (class)' => [ [ 'content' => new stdClass() ], - new MWException( '`content` field must contain a Content object.' ) + new MWException( 'content field must contain a Content object.' ) ]; yield 'with bad content object (string)' => [ [ 'content' => 'ImAGoat' ], - new MWException( '`content` field must contain a Content object.' ) + new MWException( 'content field must contain a Content object.' ) ]; yield 'bad row format' => [ 'imastring, not a row', - new MWException( 'Revision constructor passed invalid row format.' ) + new InvalidArgumentException( + '$row must be a row object, an associative array, or a RevisionRecord' + ) ]; } /** * @dataProvider provideConstructFromArrayThrowsExceptions * @covers Revision::__construct - * @covers Revision::constructFromRowArray + * @covers RevisionStore::newMutableRevisionFromArray */ public function testConstructFromArrayThrowsExceptions( $rowArray, Exception $expectedException ) { $this->setExpectedException( @@ -172,23 +174,25 @@ class RevisionTest extends MediaWikiTestCase { $expectedException->getMessage(), $expectedException->getCode() ); - new Revision( $rowArray ); + new Revision( $rowArray, 0, $this->getMockTitle() ); } /** * @covers Revision::__construct - * @covers Revision::constructFromRowArray + * @covers RevisionStore::newMutableRevisionFromArray */ public function testConstructFromNothing() { - $rev = new Revision( [] ); - $this->assertNull( $rev->getId(), 'getId()' ); + $this->setExpectedException( + InvalidArgumentException::class + ); + new Revision( [] ); } public function provideConstructFromRow() { yield 'Full construction' => [ [ - 'rev_id' => '2', - 'rev_page' => '1', + 'rev_id' => '42', + 'rev_page' => '23', 'rev_text_id' => '2', 'rev_timestamp' => '20171017114835', 'rev_user_text' => '127.0.0.1', @@ -205,8 +209,8 @@ class RevisionTest extends MediaWikiTestCase { 'rev_content_model' => 'GOATMODEL', ], function ( RevisionTest $testCase, Revision $rev ) { - $testCase->assertSame( 2, $rev->getId() ); - $testCase->assertSame( 1, $rev->getPage() ); + $testCase->assertSame( 42, $rev->getId() ); + $testCase->assertSame( 23, $rev->getPage() ); $testCase->assertSame( 2, $rev->getTextId() ); $testCase->assertSame( '20171017114835', $rev->getTimestamp() ); $testCase->assertSame( '127.0.0.1', $rev->getUserText() ); @@ -221,10 +225,10 @@ class RevisionTest extends MediaWikiTestCase { $testCase->assertSame( 'GOATMODEL', $rev->getContentModel() ); } ]; - yield 'null fields' => [ + yield 'default field values' => [ [ - 'rev_id' => '2', - 'rev_page' => '1', + 'rev_id' => '42', + 'rev_page' => '23', 'rev_text_id' => '2', 'rev_timestamp' => '20171017114835', 'rev_user_text' => '127.0.0.1', @@ -236,11 +240,24 @@ class RevisionTest extends MediaWikiTestCase { 'rev_comment_cid' => null, ], function ( RevisionTest $testCase, Revision $rev ) { - $testCase->assertNull( $rev->getSize() ); - $testCase->assertNull( $rev->getParentId() ); - $testCase->assertNull( $rev->getSha1() ); - $testCase->assertSame( 'text/x-wiki', $rev->getContentFormat() ); - $testCase->assertSame( 'wikitext', $rev->getContentModel() ); + // parent ID may be null + $testCase->assertSame( null, $rev->getParentId(), 'revision id' ); + + // given fields + $testCase->assertSame( $rev->getTimestamp(), '20171017114835', 'timestamp' ); + $testCase->assertSame( $rev->getUserText(), '127.0.0.1', 'user name' ); + $testCase->assertSame( $rev->getUser(), 0, 'user id' ); + $testCase->assertSame( $rev->getComment(), 'Goat Comment!' ); + $testCase->assertSame( false, $rev->isMinor(), 'minor edit' ); + $testCase->assertSame( 0, $rev->getVisibility(), 'visibility flags' ); + + // computed fields + $testCase->assertNotNull( $rev->getSize(), 'size' ); + $testCase->assertNotNull( $rev->getSha1(), 'hash' ); + + // NOTE: model and format will be detected based on the namespace of the (mock) title + $testCase->assertSame( 'text/x-wiki', $rev->getContentFormat(), 'format' ); + $testCase->assertSame( 'wikitext', $rev->getContentModel(), 'model' ); } ]; } @@ -248,11 +265,34 @@ class RevisionTest extends MediaWikiTestCase { /** * @dataProvider provideConstructFromRow * @covers Revision::__construct - * @covers Revision::constructFromRowArray + * @covers RevisionStore::newMutableRevisionFromArray */ public function testConstructFromRow( array $arrayData, $assertions ) { + $data = 'Hello goat.'; // needs to match model and format + + $blobStore = $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor() + ->getMock(); + + $blobStore->method( 'getBlob' ) + ->will( $this->returnValue( $data ) ); + + $blobStore->method( 'getTextIdFromAddress' ) + ->will( $this->returnCallback( + function ( $address ) { + // Turn "tt:1234" into 12345. + // Note that this must be functional so we can test getTextId(). + // Ideally, we'd un-mock getTextIdFromAddress and use its actual implementation. + $parts = explode( ':', $address ); + return (int)array_pop( $parts ); + } + ) ); + + // Note override internal service, so RevisionStore uses it as well. + $this->setService( '_SqlBlobStore', $blobStore ); + $row = (object)$arrayData; - $rev = new Revision( $row ); + $rev = new Revision( $row, 0, $this->getMockTitle() ); $assertions( $this, $rev ); } @@ -282,7 +322,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getId */ public function testGetId( $rowArray, $expectedId ) { - $rev = new Revision( $rowArray ); + $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); $this->assertEquals( $expectedId, $rev->getId() ); } @@ -296,7 +336,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::setId */ public function testSetId( $input, $expected ) { - $rev = new Revision( [] ); + $rev = new Revision( [], 0, $this->getMockTitle() ); $rev->setId( $input ); $this->assertSame( $expected, $rev->getId() ); } @@ -311,7 +351,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::setUserIdAndName */ public function testSetUserIdAndName( $inputId, $expectedId, $name ) { - $rev = new Revision( [] ); + $rev = new Revision( [], 0, $this->getMockTitle() ); $rev->setUserIdAndName( $inputId, $name ); $this->assertSame( $expectedId, $rev->getUser( Revision::RAW ) ); $this->assertEquals( $name, $rev->getUserText( Revision::RAW ) ); @@ -328,7 +368,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getTextId() */ public function testGetTextId( $rowArray, $expected ) { - $rev = new Revision( $rowArray ); + $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); $this->assertSame( $expected, $rev->getTextId() ); } @@ -343,7 +383,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getParentId() */ public function testGetParentId( $rowArray, $expected ) { - $rev = new Revision( $rowArray ); + $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); $this->assertSame( $expected, $rev->getParentId() ); } @@ -376,9 +416,44 @@ class RevisionTest extends MediaWikiTestCase { $this->testGetRevisionText( $expected, $rowData ); } + private function getWANObjectCache() { + return new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + } + + /** + * @return SqlBlobStore + */ + private function getBlobStore() { + /** @var LoadBalancer $lb */ + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + $cache = $this->getWANObjectCache(); + + $blobStore = new SqlBlobStore( $lb, $cache ); + return $blobStore; + } + + /** + * @return RevisionStore + */ + private function getRevisionStore() { + /** @var LoadBalancer $lb */ + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + $cache = $this->getWANObjectCache(); + + $blobStore = new RevisionStore( $lb, $this->getBlobStore(), $cache ); + return $blobStore; + } + public function provideGetRevisionTextWithLegacyEncoding() { yield 'Utf8Native' => [ "Wiki est l'\xc3\xa9cole superieur !", + 'fr', 'iso-8859-1', [ 'old_flags' => 'utf-8', @@ -387,6 +462,7 @@ class RevisionTest extends MediaWikiTestCase { ]; yield 'Utf8Legacy' => [ "Wiki est l'\xc3\xa9cole superieur !", + 'fr', 'iso-8859-1', [ 'old_flags' => '', @@ -399,8 +475,11 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText * @dataProvider provideGetRevisionTextWithLegacyEncoding */ - public function testGetRevisionWithLegacyEncoding( $expected, $encoding, $rowData ) { - $this->setMwGlobals( 'wgLegacyEncoding', $encoding ); + public function testGetRevisionWithLegacyEncoding( $expected, $lang, $encoding, $rowData ) { + $blobStore = $this->getBlobStore(); + $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) ); + $this->setService( 'BlobStore', $blobStore ); + $this->testGetRevisionText( $expected, $rowData ); } @@ -412,6 +491,7 @@ class RevisionTest extends MediaWikiTestCase { */ yield 'Utf8NativeGzip' => [ "Wiki est l'\xc3\xa9cole superieur !", + 'fr', 'iso-8859-1', [ 'old_flags' => 'gzip,utf-8', @@ -420,6 +500,7 @@ class RevisionTest extends MediaWikiTestCase { ]; yield 'Utf8LegacyGzip' => [ "Wiki est l'\xc3\xa9cole superieur !", + 'fr', 'iso-8859-1', [ 'old_flags' => 'gzip', @@ -432,9 +513,13 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText * @dataProvider provideGetRevisionTextWithGzipAndLegacyEncoding */ - public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $encoding, $rowData ) { + public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $lang, $encoding, $rowData ) { $this->checkPHPExtension( 'zlib' ); - $this->setMwGlobals( 'wgLegacyEncoding', $encoding ); + + $blobStore = $this->getBlobStore(); + $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) ); + $this->setService( 'BlobStore', $blobStore ); + $this->testGetRevisionText( $expected, $rowData ); } @@ -460,7 +545,10 @@ class RevisionTest extends MediaWikiTestCase { */ public function testCompressRevisionTextUtf8Gzip() { $this->checkPHPExtension( 'zlib' ); - $this->setMwGlobals( 'wgCompressRevisions', true ); + + $blobStore = $this->getBlobStore(); + $blobStore->setCompressBlobs( true ); + $this->setService( 'BlobStore', $blobStore ); $row = new stdClass; $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; @@ -475,20 +563,41 @@ class RevisionTest extends MediaWikiTestCase { Revision::getRevisionText( $row ), "getRevisionText" ); } - public function provideFetchFromConds() { - yield [ 0, [] ]; - yield [ Revision::READ_LOCKING, [ 'FOR UPDATE' ] ]; - } - /** - * @dataProvider provideFetchFromConds - * @covers Revision::fetchFromConds + * @covers Revision::loadFromTitle */ - public function testFetchFromConds( $flags, array $options ) { - $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); - $conditions = [ 'conditionsArray' ]; + public function testLoadFromTitle() { + $title = $this->getMockTitle(); + + $conditions = [ + 'rev_id=page_latest', + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ]; + + $row = (object)[ + 'rev_id' => '42', + 'rev_page' => $title->getArticleID(), + 'rev_text_id' => '2', + 'rev_timestamp' => '20171017114835', + 'rev_user_text' => '127.0.0.1', + 'rev_user' => '0', + 'rev_minor_edit' => '0', + 'rev_deleted' => '0', + 'rev_len' => '46', + 'rev_parent_id' => '1', + 'rev_sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', + 'rev_comment_text' => 'Goat Comment!', + 'rev_comment_data' => null, + 'rev_comment_cid' => null, + 'rev_content_format' => 'GOATFORMAT', + 'rev_content_model' => 'GOATMODEL', + ]; $db = $this->getMock( IDatabase::class ); + $db->expects( $this->any() ) + ->method( 'getDomainId' ) + ->will( $this->returnValue( wfWikiID() ) ); $db->expects( $this->once() ) ->method( 'selectRow' ) ->with( @@ -497,17 +606,24 @@ class RevisionTest extends MediaWikiTestCase { $this->isType( 'array' ), $this->equalTo( $conditions ), // Method name - $this->equalTo( 'Revision::fetchFromConds' ), - $this->equalTo( $options ), + $this->stringContains( 'fetchRevisionRowFromConds' ), + // We don't really care about the options here + $this->isType( 'array' ), // We don't really care about the join conds are they come from the joinCond methods $this->isType( 'array' ) ) - ->willReturn( 'RETURNVALUE' ); - - $wrapper = TestingAccessWrapper::newFromClass( Revision::class ); - $result = $wrapper->fetchFromConds( $db, $conditions, $flags ); - - $this->assertEquals( 'RETURNVALUE', $result ); + ->willReturn( $row ); + + $revision = Revision::loadFromTitle( $db, $title ); + + $this->assertEquals( $title->getArticleID(), $revision->getTitle()->getArticleID() ); + $this->assertEquals( $row->rev_id, $revision->getId() ); + $this->assertEquals( $row->rev_len, $revision->getSize() ); + $this->assertEquals( $row->rev_sha1, $revision->getSha1() ); + $this->assertEquals( $row->rev_parent_id, $revision->getParentId() ); + $this->assertEquals( $row->rev_timestamp, $revision->getTimestamp() ); + $this->assertEquals( $row->rev_comment_text, $revision->getComment() ); + $this->assertEquals( $row->rev_user_text, $revision->getUserText() ); } public function provideDecompressRevisionText() { @@ -572,8 +688,12 @@ class RevisionTest extends MediaWikiTestCase { * @param mixed $expected */ public function testDecompressRevisionText( $legacyEncoding, $text, $flags, $expected ) { - $this->setMwGlobals( 'wgLegacyEncoding', $legacyEncoding ); - $this->setMwGlobals( 'wgLanguageCode', 'en' ); + $blobStore = $this->getBlobStore(); + if ( $legacyEncoding ) { + $blobStore->setLegacyEncoding( $legacyEncoding, Language::factory( 'en' ) ); + } + + $this->setService( 'BlobStore', $blobStore ); $this->assertSame( $expected, Revision::decompressRevisionText( $text, $flags ) @@ -669,14 +789,20 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText */ public function testGetRevisionText_external_oldId() { - $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $cache = $this->getWANObjectCache(); $this->setService( 'MainWANObjectCache', $cache ); + $this->setService( 'ExternalStoreFactory', new ExternalStoreFactory( [ 'ForTesting' ] ) ); - $cacheKey = $cache->makeKey( 'revisiontext', 'textid', '7777' ); + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + $blobStore = new SqlBlobStore( $lb, $cache ); + $this->setService( 'BlobStore', $blobStore ); $this->assertSame( 'AAAABBAAA', @@ -688,6 +814,8 @@ class RevisionTest extends MediaWikiTestCase { ] ) ); + + $cacheKey = $cache->makeKey( 'revisiontext', 'textid', 'tt:7777' ); $this->assertSame( 'AAAABBAAA', $cache->get( $cacheKey ) ); } @@ -883,6 +1011,8 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', + 'ar_namespace', + 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -911,6 +1041,8 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', + 'ar_namespace', + 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -944,6 +1076,8 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', + 'ar_namespace', + 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -980,6 +1114,8 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', + 'ar_namespace', + 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1016,6 +1152,8 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', + 'ar_namespace', + 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1047,6 +1185,11 @@ class RevisionTest extends MediaWikiTestCase { */ public function testGetArchiveQueryInfo( $globals, $expected ) { $this->setMwGlobals( $globals ); + + $revisionStore = $this->getRevisionStore(); + $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] ); + $this->setService( 'RevisionStore', $revisionStore ); + $this->assertEquals( $expected, Revision::getArchiveQueryInfo() @@ -1398,6 +1541,11 @@ class RevisionTest extends MediaWikiTestCase { */ public function testGetQueryInfo( $globals, $options, $expected ) { $this->setMwGlobals( $globals ); + + $revisionStore = $this->getRevisionStore(); + $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] ); + $this->setService( 'RevisionStore', $revisionStore ); + $this->assertEquals( $expected, Revision::getQueryInfo( $options )