From: jenkins-bot Date: Tue, 19 Dec 2017 13:10:53 +0000 (+0000) Subject: Merge "Revert "[MCR] Turn Revision into a proxy to new code."" X-Git-Tag: 1.31.0-rc.0~1165 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=3f484f6241a104338f1f7408b859374505cb0aa8;hp=d6b0b9253544dae85d95e8f7e02c8d6a13a6fef4 Merge "Revert "[MCR] Turn Revision into a proxy to new code."" --- diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index 67026f448e..1a1a9f71e6 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -123,9 +123,6 @@ 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 1f4a5f4da4..ee38ea9d4a 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 -'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! +'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. '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 ea73a61bbf..25c89c26ec 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -20,14 +20,7 @@ * @file */ -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\Database; use Wikimedia\Rdbms\IDatabase; use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; @@ -35,50 +28,78 @@ use Wikimedia\Rdbms\ResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; /** - * @deprecated since 1.31, use RevisionRecord, RevisionStore, and BlobStore instead. + * @todo document */ class Revision implements IDBAccessObject { - - /** @var RevisionRecord */ - protected $mRecord; + /** @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; // Revision deletion constants - 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; + 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 // Audience options for accessors - 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 FOR_PUBLIC = 1; + const FOR_THIS_USER = 2; + const RAW = 3; - /** - * @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; - } + const TEXT_CACHE_GROUP = 'revisiontext:10'; // process cache name and max key count /** * Load a page revision from a given revision ID number. @@ -93,8 +114,7 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromId( $id, $flags = 0 ) { - $rec = self::getRevisionStore()->getRevisionById( $id, $flags ); - return $rec === null ? null : new Revision( $rec, $flags ); + return self::newFromConds( [ 'rev_id' => intval( $id ) ], $flags ); } /** @@ -112,8 +132,20 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromTitle( LinkTarget $linkTarget, $id = 0, $flags = 0 ) { - $rec = self::getRevisionStore()->getRevisionByTitle( $linkTarget, $id, $flags ); - return $rec === null ? null : new Revision( $rec, $flags ); + $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 ); + } } /** @@ -131,13 +163,22 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public static function newFromPageId( $pageId, $revId = 0, $flags = 0 ) { - $rec = self::getRevisionStore()->getRevisionByPageId( $pageId, $revId, $flags ); - return $rec === null ? null : new Revision( $rec, $flags ); + $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 ); + } } /** * 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 @@ -146,45 +187,68 @@ class Revision implements IDBAccessObject { * @return Revision */ public static function newFromArchiveRow( $row, $overrides = [] ) { - $rec = self::getRevisionStore()->newRevisionFromArchiveRow( $row, 0, null, $overrides ); - return new Revision( $rec ); + 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 ); } /** * @since 1.19 * - * 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 + * @param object $row * @return Revision */ public static function newFromRow( $row ) { - if ( is_array( $row ) ) { - $rec = self::getRevisionStore()->newMutableRevisionFromArray( $row ); - } else { - $rec = self::getRevisionStore()->newRevisionFromRow( $row ); - } - - return new Revision( $rec ); + return new self( $row ); } /** * 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 ) { - wfDeprecated( __METHOD__, '1.31' ); // no known callers - $rec = self::getRevisionStore()->loadRevisionFromId( $db, $id ); - return $rec === null ? null : new Revision( $rec ); + return self::loadFromConds( $db, [ 'rev_id' => intval( $id ) ] ); } /** @@ -192,16 +256,19 @@ 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 ) { - $rec = self::getRevisionStore()->loadRevisionFromPageId( $db, $pageid, $id ); - return $rec === null ? null : new Revision( $rec ); + $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 ); } /** @@ -209,16 +276,24 @@ 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 ) { - $rec = self::getRevisionStore()->loadRevisionFromTitle( $db, $title, $id ); - return $rec === null ? null : new Revision( $rec ); + if ( $id ) { + $matchId = intval( $id ); + } else { + $matchId = 'page_latest'; + } + return self::loadFromConds( $db, + [ + "rev_id=$matchId", + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ] + ); } /** @@ -226,17 +301,73 @@ 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 ) { - // XXX: replace loadRevisionFromTimestamp by getRevisionByTimestamp? - $rec = self::getRevisionStore()->loadRevisionFromTimestamp( $db, $title, $timestamp ); - return $rec === null ? null : new Revision( $rec ); + 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; } /** @@ -246,18 +377,52 @@ class Revision implements IDBAccessObject { * * @param LinkTarget $title * @return ResultWrapper - * @deprecated Since 1.28, no callers in core nor in known extensions. No-op since 1.31. + * @deprecated Since 1.28 */ public static function fetchRevision( LinkTarget $title ) { - wfDeprecated( __METHOD__, '1.31' ); - return new FakeResultWrapper( [] ); + $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'] + ); } /** * 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 RevisionStore::getQueryInfo( [ 'user' ] ) instead. + * @deprecated since 1.31, use self::getQueryInfo( [ 'user' ] ) instead. * @return array */ public static function userJoinCond() { @@ -269,7 +434,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 RevisionStore::getQueryInfo( [ 'page' ] ) instead. + * @deprecated since 1.31, use self::getQueryInfo( [ 'page' ] ) instead. * @return array */ public static function pageJoinCond() { @@ -280,7 +445,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 RevisionStore::getQueryInfo() instead. + * @deprecated since 1.31, use self::getQueryInfo() instead. * @return array */ public static function selectFields() { @@ -315,7 +480,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 RevisionStore::getArchiveQueryInfo() instead. + * @deprecated since 1.31, use self::getArchiveQueryInfo() instead. * @return array */ public static function selectArchiveFields() { @@ -351,7 +516,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 RevisionStore::getQueryInfo( [ 'text' ] ) instead. + * @deprecated since 1.31, use self::getQueryInfo( [ 'text' ] ) instead. * @return array */ public static function selectTextFields() { @@ -364,7 +529,7 @@ class Revision implements IDBAccessObject { /** * Return the list of page fields that should be selected from page table - * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'page' ] ) instead. + * @deprecated since 1.31, use self::getQueryInfo( [ 'page' ] ) instead. * @return array */ public static function selectPageFields() { @@ -381,7 +546,7 @@ class Revision implements IDBAccessObject { /** * Return the list of user fields that should be selected from user table - * @deprecated since 1.31, use RevisionStore::getQueryInfo( [ 'user' ] ) instead. + * @deprecated since 1.31, use self::getQueryInfo( [ 'user' ] ) instead. * @return array */ public static function selectUserFields() { @@ -393,7 +558,6 @@ 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 @@ -404,21 +568,104 @@ class Revision implements IDBAccessObject { * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` */ public static function getQueryInfo( $options = [] ) { - return self::getRevisionStore()->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 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() { - return self::getRevisionStore()->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; } /** @@ -428,49 +675,203 @@ class Revision implements IDBAccessObject { * @return array */ public static function getParentLengths( $db, array $revIds ) { - return self::getRevisionStore()->listRevisionSizes( $db, $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; } /** - * @param object|array|RevisionRecord $row Either a database row or an array - * @param int $queryFlags - * @param Title|null $title - * + * @param object|array $row Either a database row or an array + * @throws MWException * @access private */ - function __construct( $row, $queryFlags = 0, Title $title = null ) { - global $wgUser; - - if ( $row instanceof RevisionRecord ) { - $this->mRecord = $row; + public function __construct( $row ) { + if ( is_object( $row ) ) { + $this->constructFromDbRowObject( $row ); } elseif ( is_array( $row ) ) { - if ( !isset( $row['user'] ) && !isset( $row['user_text'] ) ) { - $row['user'] = $wgUser; - } + $this->constructFromRowArray( $row ); + } else { + throw new MWException( 'Revision constructor passed invalid row format.' ); + } + $this->mUnpatrolled = null; + } - $this->mRecord = self::getRevisionStore()->newMutableRevisionFromArray( - $row, - $queryFlags, - $title - ); - } elseif ( is_object( $row ) ) { - $this->mRecord = self::getRevisionStore()->newRevisionFromRow( - $row, - $queryFlags, - $title - ); + /** + * @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 { - throw new InvalidArgumentException( - '$row must be a row object, an associative array, or a RevisionRecord' - ); + $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 ); + } + + // Lazy extraction... + $this->mText = null; + if ( isset( $row->old_text ) ) { + $this->mTextRow = $row; + } 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 + } + $this->mOrigUserText = $row->rev_user_text; } /** - * @return RevisionRecord + * @param array $row + * + * @throws MWException */ - public function getRevisionRecord() { - return $this->mRecord; + 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(); } /** @@ -479,27 +880,19 @@ class Revision implements IDBAccessObject { * @return int|null */ public function getId() { - return $this->mRecord->getId(); + return $this->mId; } /** * Set the revision ID * - * 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. + * This should only be used for proposed revisions that turn out to be null edits * * @since 1.19 - * @param int|string $id - * @throws MWException + * @param int $id */ public function setId( $id ) { - if ( $this->mRecord instanceof MutableRevisionRecord ) { - $this->mRecord->setId( intval( $id ) ); - } else { - throw new MWException( __METHOD__ . ' is not supported on this instance' ); - } + $this->mId = (int)$id; } /** @@ -507,107 +900,106 @@ 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 ) { - 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' ); - } + $this->mUser = (int)$id; + $this->mUserText = $name; + $this->mOrigUserText = $name; } /** - * @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. + * Get text row ID * * @return int|null */ public function getTextId() { - $slot = $this->getMainSlotRaw(); - return $slot->hasAddress() - ? self::getBlobStore()->getTextIdFromAddress( $slot->getAddress() ) - : null; + return $this->mTextId; } /** * Get parent revision ID (the original previous page revision) * - * @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. + * @return int|null */ public function getParentId() { - return $this->mRecord->getParentId(); + return $this->mParentId; } /** * Returns the length of the text in this revision, or null if unknown. * - * @return int + * @return int|null */ public function getSize() { - return $this->mRecord->getSize(); + return $this->mSize; } /** - * Returns the base36 sha1 of the content in this revision, or null if unknown. + * Returns the base36 sha1 of the text in this revision, or null if unknown. * - * @return string + * @return string|null */ public function getSha1() { - // XXX: we may want to drop all the hashing logic, it's not worth the overhead. - return $this->mRecord->getSha1(); + return $this->mSha1; } /** - * Returns the title of the page associated with this entry. - * Since 1.31, this will never return null. + * Returns the title of the page associated with this entry or null. * * Will do a query, when title is not set and id is given. * - * @return Title + * @return Title|null */ public function getTitle() { - $linkTarget = $this->mRecord->getPageAsLinkTarget(); - return Title::newFromLinkTarget( $linkTarget ); + 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; } /** * 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 ) { - if ( !$title->equals( $this->getTitle() ) ) { - throw new InvalidArgumentException( - $title->getPrefixedText() - . ' is not the same as ' - . $this->mRecord->getPageAsLinkTarget()->__toString() - ); - } + $this->mTitle = $title; } /** @@ -616,7 +1008,7 @@ class Revision implements IDBAccessObject { * @return int|null */ public function getPage() { - return $this->mRecord->getPageId(); + return $this->mPage; } /** @@ -633,14 +1025,13 @@ class Revision implements IDBAccessObject { * @return int */ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { - global $wgUser; - - if ( $audience === self::FOR_THIS_USER && !$user ) { - $user = $wgUser; + 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; } - - $user = $this->mRecord->getUser( $audience, $user ); - return $user ? $user->getId() : 0; } /** @@ -668,14 +1059,23 @@ class Revision implements IDBAccessObject { * @return string */ public function getUserText( $audience = self::FOR_PUBLIC, User $user = null ) { - global $wgUser; + $this->loadMutableFields(); - if ( $audience === self::FOR_THIS_USER && !$user ) { - $user = $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; } - - $user = $this->mRecord->getUser( $audience, $user ); - return $user ? $user->getName() : ''; } /** @@ -703,14 +1103,13 @@ class Revision implements IDBAccessObject { * @return string */ function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { - global $wgUser; - - if ( $audience === self::FOR_THIS_USER && !$user ) { - $user = $wgUser; + 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; } - - $comment = $this->mRecord->getComment( $audience, $user ); - return $comment === null ? null : $comment->text; } /** @@ -728,14 +1127,23 @@ class Revision implements IDBAccessObject { * @return bool */ public function isMinor() { - return $this->mRecord->isMinor(); + return (bool)$this->mMinorEdit; } /** * @return int Rcid of the unpatrolled row, zero if there isn't one */ public function isUnpatrolled() { - return self::getRevisionStore()->isUnpatrolled( $this->mRecord ); + 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; } /** @@ -748,7 +1156,19 @@ class Revision implements IDBAccessObject { * @return RecentChange|null */ public function getRecentChange( $flags = 0 ) { - return self::getRevisionStore()->getRecentChange( $this->mRecord, $flags ); + $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 + ); } /** @@ -757,7 +1177,14 @@ class Revision implements IDBAccessObject { * @return bool */ public function isDeleted( $field ) { - return $this->mRecord->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; } /** @@ -766,17 +1193,19 @@ class Revision implements IDBAccessObject { * @return int */ public function getVisibility() { - return $this->mRecord->getVisibility(); + $this->loadMutableFields(); + + return (int)$this->mDeleted; } /** * Fetch revision content if it's available to the specified audience. * If the specified audience does not have the ability to view this - * revision, or the content could not be loaded, null will be returned. + * revision, 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 $user + * Revision::FOR_THIS_USER to be displayed to $wgUser * 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 @@ -784,17 +1213,12 @@ class Revision implements IDBAccessObject { * @return Content|null */ public function getContent( $audience = self::FOR_PUBLIC, User $user = null ) { - global $wgUser; - - if ( $audience === self::FOR_THIS_USER && !$user ) { - $user = $wgUser; - } - - try { - return $this->mRecord->getContent( 'main', $audience, $user ); - } - catch ( RevisionAccessException $e ) { + if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_TEXT ) ) { + return null; + } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_TEXT, $user ) ) { return null; + } else { + return $this->getContentInternal(); } } @@ -802,51 +1226,86 @@ 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() { - $slot = $this->getMainSlotRaw(); - return $slot->getContent()->serialize(); + if ( $this->mText === null ) { + // Revision is immutable. Load on demand. + $this->mText = $this->loadText(); + } + + return $this->mText; } /** - * Returns the content model for the main slot of this revision. + * 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. * * 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() { - return $this->getMainSlotRaw()->getModel(); + 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; } /** - * Returns the content format for the main slot of this revision. + * Returns the content format for 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() { - $format = $this->getMainSlotRaw()->getFormat(); + if ( !$this->mContentFormat ) { + $handler = $this->getContentHandler(); + $this->mContentFormat = $handler->getDefaultFormat(); - if ( $format === null ) { - // if no format was stored along with the blob, fall back to default format - $format = $this->getContentHandler()->getDefaultFormat(); + assert( !empty( $this->mContentFormat ) ); } - return $format; + return $this->mContentFormat; } /** @@ -856,21 +1315,33 @@ class Revision implements IDBAccessObject { * @return ContentHandler */ public function getContentHandler() { - return ContentHandler::getForModelID( $this->getContentModel() ); + 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 string */ public function getTimestamp() { - return $this->mRecord->getTimestamp(); + return wfTimestamp( TS_MW, $this->mTimestamp ); } /** * @return bool */ public function isCurrent() { - return ( $this->mRecord instanceof RevisionStoreRecord ) && $this->mRecord->isCurrent(); + return $this->mCurrent; } /** @@ -879,8 +1350,13 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public function getPrevious() { - $rec = self::getRevisionStore()->getPreviousRevision( $this->mRecord ); - return $rec === null ? null : new Revision( $rec ); + if ( $this->getTitle() ) { + $prev = $this->getTitle()->getPreviousRevisionID( $this->getId() ); + if ( $prev ) { + return self::newFromTitle( $this->getTitle(), $prev ); + } + } + return null; } /** @@ -889,8 +1365,38 @@ class Revision implements IDBAccessObject { * @return Revision|null */ public function getNext() { - $rec = self::getRevisionStore()->getNextRevision( $this->mRecord ); - return $rec === null ? null : new Revision( $rec ); + 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 ); } /** @@ -923,9 +1429,35 @@ class Revision implements IDBAccessObject { return false; } - $cacheKey = isset( $row->old_id ) ? ( 'tt:' . $row->old_id ) : null; + // 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 ] ); + } + } - return self::getBlobStore()->expandBlob( $text, $flags, $cacheKey ); + return self::decompressRevisionText( $text, $flags ); } /** @@ -939,7 +1471,28 @@ class Revision implements IDBAccessObject { * @return string */ public static function compressRevisionText( &$text ) { - return self::getBlobStore()->compressData( $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 ); } /** @@ -950,7 +1503,46 @@ class Revision implements IDBAccessObject { * @return string|bool Decompressed text, or false on failure */ public static function decompressRevisionText( $text, $flags ) { - return self::getBlobStore()->decompressData( $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; } /** @@ -962,27 +1554,192 @@ class Revision implements IDBAccessObject { * @return int The revision ID */ public function insertOn( $dbw ) { - global $wgUser; + 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" ); + } - // Note that $this->mRecord->getId() will typically return null here, but not always, - // e.g. not when restoring a revision. + $this->checkContentModel(); - 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.' ); + $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!" ); } + + $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; } - $rec = self::getRevisionStore()->insertRevisionOn( $this->mRecord, $dbw ); + $dbw->insert( 'revision', $row, __METHOD__ ); - $this->mRecord = $rec; + if ( $this->mId === null ) { + // Only if auto-increment was used + $this->mId = $dbw->insertId(); + } + $commentCallback( $this->mId ); - // TODO: hard-deprecate in 1.32 (or even 1.31?) - Hooks::run( 'RevisionInsertComplete', [ $this, null, null ] ); + // 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 ) + ); + } - return $rec->getId(); + // 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" + ); + } } /** @@ -991,7 +1748,103 @@ class Revision implements IDBAccessObject { * @return string */ public static function base36Sha1( $text ) { - return SlotRecord::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; } /** @@ -1010,17 +1863,58 @@ class Revision implements IDBAccessObject { * @return Revision|null Revision or null on error */ public static function newNullRevision( $dbw, $pageId, $summary, $minor, $user = null ) { - global $wgUser; - if ( !$user ) { - $user = $wgUser; + 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'; } - $comment = CommentStoreComment::newUnsavedComment( $summary, null ); + $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 ); - $title = Title::newFromID( $pageId ); - $rec = self::getRevisionStore()->newNullRevision( $dbw, $title, $comment, $minor, $user ); + $revision = new Revision( $row ); + } else { + $revision = null; + } - return new Revision( $rec ); + return $revision; } /** @@ -1054,13 +1948,35 @@ class Revision implements IDBAccessObject { public static function userCanBitfield( $bitfield, $field, User $user = null, Title $title = null ) { - global $wgUser; - - if ( !$user ) { - $user = $wgUser; + 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; } - - return RevisionRecord::userCanBitfield( $bitfield, $field, $user, $title ); } /** @@ -1072,7 +1988,18 @@ class Revision implements IDBAccessObject { * @return string|bool False if not found */ static function getTimestampFromId( $title, $id, $flags = 0 ) { - return self::getRevisionStore()->getTimestampFromId( $title, $id, $flags ); + $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; } /** @@ -1083,7 +2010,12 @@ class Revision implements IDBAccessObject { * @return int */ static function countByPageId( $db, $id ) { - return self::getRevisionStore()->countRevisionsByPageId( $db, $id ); + $row = $db->selectRow( 'revision', [ 'revCount' => 'COUNT(*)' ], + [ 'rev_page' => $id ], __METHOD__ ); + if ( $row ) { + return $row->revCount; + } + return 0; } /** @@ -1094,7 +2026,11 @@ class Revision implements IDBAccessObject { * @return int */ static function countByTitle( $db, $title ) { - return self::getRevisionStore()->countRevisionsByTitle( $db, $title ); + $id = $title->getArticleID(); + if ( $id ) { + return self::countByPageId( $db, $id ); + } + return 0; } /** @@ -1114,11 +2050,28 @@ 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 ); } - return self::getRevisionStore()->userWasLastToEdit( $db, $pageId, $userId, $since ); + $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; } /** @@ -1126,20 +2079,54 @@ 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 loaded if $pageIdOrTitle is an integer ID. + * The title will also be lazy loaded, though setTitle() can be used to preload it. * - * @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. + * @param IDatabase $db + * @param int $pageId Page ID + * @param int $revId Known current revision of this page * @return Revision|bool Returns false if missing * @since 1.28 */ - public static function newKnownCurrent( IDatabase $db, $pageIdOrTitle, $revId = 0 ) { - $title = $pageIdOrTitle instanceof Title - ? $pageIdOrTitle - : Title::newFromID( $pageIdOrTitle ); + 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 + } - $record = self::getRevisionStore()->getKnownCurrentRevision( $title, $revId ); - return $record ? new Revision( $record ) : false; + $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; + } } } diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 575970d23f..d21bcef332 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -450,46 +450,6 @@ 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 85e8db63b0..0e964bf5cc 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, 0, $this->getTitle() ); - + $rev = new Revision( $row ); + $rev->setTitle( $this->getTitle() ); $text = FeedUtils::formatDiffRow( $this->getTitle(), $this->getTitle()->getPreviousRevisionID( $rev->getId() ), @@ -639,10 +639,12 @@ class HistoryPager extends ReverseChronologicalPager { */ function historyLine( $row, $next, $notificationtimestamp = false, $latest = false, $firstInList = false ) { - $rev = new Revision( $row, 0, $this->getTitle() ); + $rev = new Revision( $row ); + $rev->setTitle( $this->getTitle() ); if ( is_object( $next ) ) { - $prevRev = new Revision( $next, 0, $this->getTitle() ); + $prevRev = new Revision( $next ); + $prevRev->setTitle( $this->getTitle() ); } else { $prevRev = null; } diff --git a/includes/cache/MessageCache.php b/includes/cache/MessageCache.php index d6e9b748ab..768f980b26 100644 --- a/includes/cache/MessageCache.php +++ b/includes/cache/MessageCache.php @@ -1048,7 +1048,8 @@ class MessageCache { if ( $titleObj->getLatestRevID() ) { $revision = Revision::newKnownCurrent( $dbr, - $titleObj + $titleObj->getArticleID(), + $titleObj->getLatestRevID() ); } else { $revision = false; diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index c37566bdf6..ac9cd84403 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -23,7 +23,6 @@ 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; @@ -672,7 +671,7 @@ class WikiPage implements Page, IDBAccessObject { $revision = Revision::newFromPageId( $this->getId(), $latest, $flags ); } else { $dbr = wfGetDB( DB_REPLICA ); - $revision = Revision::newKnownCurrent( $dbr, $this->getTitle(), $latest ); + $revision = Revision::newKnownCurrent( $dbr, $this->getId(), $latest ); } if ( $revision ) { // sanity @@ -1265,11 +1264,8 @@ 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' => $revId, + 'page_latest' => $revision->getId(), '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 10a338ed01..13d8a3aa0e 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -3498,7 +3498,13 @@ class Parser { * @return Revision|bool False if missing */ public static function statelessFetchRevision( Title $title, $parser = false ) { - $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); + $pageId = $title->getArticleID(); + $revId = $title->getLatestRevID(); + + $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $pageId, $revId ); + if ( $rev ) { + $rev->setTitle( $title ); + } return $rev; } diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index 6eddfc0923..bebc1887dc 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -183,10 +183,12 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { * @return Content|null */ protected function getContentObj( Title $title ) { - $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); + $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title->getArticleID(), + $title->getLatestRevID() ); 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 1639386e3b..671ab6fb55 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -290,16 +290,15 @@ class SpecialNewpages extends IncludableSpecialPage { /** * @param stdClass $result Result row from recent changes - * @param Title $title - * @return bool|Revision + * @return Revision|bool */ - protected function revisionFromRcResult( stdClass $result, Title $title ) { + protected function revisionFromRcResult( stdClass $result ) { 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 ); + ] ); } /** @@ -314,7 +313,8 @@ class SpecialNewpages extends IncludableSpecialPage { // Revision deletion works on revisions, // so cast our recent change row to a revision row. - $rev = $this->revisionFromRcResult( $result, $title ); + $rev = $this->revisionFromRcResult( $result ); + $rev->setTitle( $title ); $classes = []; $attribs = [ 'data-mw-revid' => $result->rev_id ]; diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php index 4d39f7b110..a5c468806f 100644 --- a/tests/phpunit/includes/MediaWikiServicesTest.php +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -7,8 +7,6 @@ use MediaWiki\Services\DestructibleService; use MediaWiki\Services\SalvageableService; use MediaWiki\Services\ServiceDisabledException; use MediaWiki\Shell\CommandFactory; -use MediaWiki\Storage\BlobStore; -use MediaWiki\Storage\RevisionStore; /** * @covers MediaWiki\MediaWikiServices @@ -333,8 +331,6 @@ class MediaWikiServicesTest extends MediaWikiTestCase { 'LocalServerObjectCache' => [ 'LocalServerObjectCache', BagOStuff::class ], 'VirtualRESTServiceClient' => [ 'VirtualRESTServiceClient', VirtualRESTServiceClient::class ], 'ShellCommandFactory' => [ 'ShellCommandFactory', CommandFactory::class ], - 'BlobStore' => [ 'BlobStore', BlobStore::class ], - 'RevisionStore' => [ 'RevisionStore', RevisionStore::class ], ]; } diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index 9ab76c8832..91dbf2cf70 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -1,8 +1,4 @@ resetNamespaces(); - if ( !$this->testPage ) { /** * We have to create a new page for each subclass as the page creation may result @@ -107,14 +102,6 @@ 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'; } @@ -123,10 +110,6 @@ 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 ); @@ -219,23 +202,14 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $revId = $rev->insertOn( wfGetDB( DB_MASTER ) ); $this->assertInternalType( 'integer', $revId ); - $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->assertSame( $revId, $rev->getId() ); $this->assertSelect( 'text', [ 'old_id', 'old_text' ], - "old_id = $textId", - [ [ strval( $textId ), 'Revision Text' ] ] + "old_id = {$rev->getTextId()}", + [ [ strval( $rev->getTextId() ), 'Revision Text' ] ] ); $this->assertSelect( 'revision', @@ -254,7 +228,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { [ [ strval( $rev->getId() ), strval( $this->testPage->getId() ), - strval( $textId ), + strval( $rev->getTextId() ), '0', '0', '0', @@ -272,12 +246,11 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { // If an ExternalStore is set don't use it. $this->setMwGlobals( 'wgDefaultExternalStore', false ); $this->setExpectedException( - IncompleteRevisionException::class, - "rev_page field must not be 0!" + MWException::class, + "Cannot insert revision: page ID must be nonzero" ); - $title = Title::newFromText( 'Nonexistant-' . __METHOD__ ); - $rev = new Revision( [], 0, $title ); + $rev = new Revision( [] ); $rev->insertOn( wfGetDB( DB_MASTER ) ); } @@ -348,42 +321,12 @@ 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; - }, - ]; } /** @@ -391,17 +334,6 @@ 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', @@ -422,8 +354,6 @@ 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 ); @@ -452,7 +382,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $row = $res->fetchObject(); $res->free(); - $rev = Revision::newFromArchiveRow( $row, [ 'comment_text' => 'SOMEOVERRIDE' ] ); + $rev = Revision::newFromArchiveRow( $row, [ 'comment' => 'SOMEOVERRIDE' ] ); $this->assertNotEquals( $orig->getComment(), $rev->getComment() ); $this->assertEquals( 'SOMEOVERRIDE', $rev->getComment() ); @@ -496,8 +426,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { * @covers Revision::newFromPageId */ public function testNewFromPageIdWithNotLatestId() { - $content = new WikitextContent( __METHOD__ ); - $this->testPage->doEditContent( $content, __METHOD__ ); + $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ ); $rev = Revision::newFromPageId( $this->testPage->getId(), $this->testPage->getRevision()->getPrevious()->getId() @@ -518,7 +447,6 @@ 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 @@ -527,7 +455,8 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rows[$row->rev_id] = $row; } - $this->assertEmpty( $rows, 'expected empty set' ); + $this->assertEquals( 1, count( $rows ), 'expected exactly one revision' ); + $this->assertArrayHasKey( $id, $rows, 'missing revision with id ' . $id ); } /** @@ -612,10 +541,6 @@ 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() ); } @@ -681,7 +606,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'zero', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'comment' => 'edit zero' + 'summary' => 'edit zero' ] ); $revisions[0]->insertOn( $dbw ); @@ -693,7 +618,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'one', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'comment' => 'edit one' + 'summary' => 'edit one' ] ); $revisions[1]->insertOn( $dbw ); @@ -704,7 +629,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userB->getId(), 'text' => 'two', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'comment' => 'edit two' + 'summary' => 'edit two' ] ); $revisions[2]->insertOn( $dbw ); @@ -715,7 +640,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'three', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'comment' => 'edit three' + 'summary' => 'edit three' ] ); $revisions[3]->insertOn( $dbw ); @@ -726,24 +651,13 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'user' => $userA->getId(), 'text' => 'zero', 'content_model' => CONTENT_MODEL_WIKITEXT, - 'comment' => 'edit four' + 'summary' => '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 ); @@ -891,16 +805,12 @@ 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() { @@ -994,7 +904,6 @@ 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() ) @@ -1117,7 +1026,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev[1] = $this->testPage->getLatest(); $this->assertSame( - [ $rev[1] => $textLength ], + [ $rev[1] => strval( $textLength ) ], Revision::getParentLengths( wfGetDB( DB_MASTER ), [ $rev[1] ] @@ -1140,7 +1049,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev[2] = $this->testPage->getLatest(); $this->assertSame( - [ $rev[1] => $textOneLength, $rev[2] => $textTwoLength ], + [ $rev[1] => strval( $textOneLength ), $rev[2] => strval( $textTwoLength ) ], Revision::getParentLengths( wfGetDB( DB_MASTER ), [ $rev[1], $rev[2] ] @@ -1171,6 +1080,14 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { ); } + /** + * @covers Revision::getTitle + */ + public function testGetTitle_forBadRevision() { + $rev = new Revision( [] ); + $this->assertNull( $rev->getTitle() ); + } + /** * @covers Revision::isMinor */ @@ -1346,21 +1263,14 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev = $this->testPage->getRevision(); // Clear any previous cache for the revision during creation - $key = $cache->makeGlobalKey( 'revision-row-1.29', - $db->getDomainID(), - $rev->getPage(), - $rev->getId() - ); + $key = $cache->makeGlobalKey( 'revision', $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 ); - - $cachedRow = $cache->get( $key ); - $this->assertNotFalse( $cachedRow ); - $this->assertEquals( $rev->getId(), $cachedRow->rev_id ); + $this->assertRevEquals( $rev, $cache->get( $key ) ); } public function provideUserCanBitfield() { @@ -1467,7 +1377,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { ] ); $user = $this->getTestUser( $userGroups )->getUser(); - $revision = new Revision( [ 'deleted' => $bitField ], 0, $this->testPage->getTitle() ); + $revision = new Revision( [ 'deleted' => $bitField ] ); $this->assertSame( $expected, diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php index ef55e7235a..e4ea40f9ba 100644 --- a/tests/phpunit/includes/RevisionTest.php +++ b/tests/phpunit/includes/RevisionTest.php @@ -1,9 +1,6 @@ getMockTitle() ); + public function testConstructFromArray( array $rowArray ) { + $rev = new Revision( $rowArray ); $this->assertNotNull( $rev->getContent(), 'no content object available' ); $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); @@ -68,7 +65,7 @@ class RevisionTest extends MediaWikiTestCase { /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers Revision::constructFromRowArray */ public function testConstructFromEmptyArray() { $rev = new Revision( [], 0, $this->getMockTitle() ); @@ -93,20 +90,30 @@ class RevisionTest extends MediaWikiTestCase { 99, 'SomeTextUserName', ]; - yield 'user text only' => [ + // Note: the below XXX test cases are odd and probably result in unexpected behaviour if used + // in production code. + yield 'XXX: user text only' => [ [ 'content' => new JavaScriptContent( 'hello world.' ), 'user_text' => '111.111.111.111', ], - 0, + null, '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 \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers Revision::constructFromRowArray * * @param array $rowArray * @param mixed $expectedUserId null to expect the current wgUser ID @@ -126,7 +133,7 @@ class RevisionTest extends MediaWikiTestCase { $expectedUserName = $testUser->getName(); } - $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); + $rev = new Revision( $rowArray ); $this->assertEquals( $expectedUserId, $rev->getUser() ); $this->assertEquals( $expectedUserName, $rev->getUserText() ); } @@ -136,37 +143,28 @@ 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 InvalidArgumentException( - '$row must be a row object, an associative array, or a RevisionRecord' - ) + new MWException( 'Revision constructor passed invalid row format.' ) ]; } /** * @dataProvider provideConstructFromArrayThrowsExceptions * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers Revision::constructFromRowArray */ public function testConstructFromArrayThrowsExceptions( $rowArray, Exception $expectedException ) { $this->setExpectedException( @@ -174,25 +172,23 @@ class RevisionTest extends MediaWikiTestCase { $expectedException->getMessage(), $expectedException->getCode() ); - new Revision( $rowArray, 0, $this->getMockTitle() ); + new Revision( $rowArray ); } /** * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray + * @covers Revision::constructFromRowArray */ public function testConstructFromNothing() { - $this->setExpectedException( - InvalidArgumentException::class - ); - new Revision( [] ); + $rev = new Revision( [] ); + $this->assertNull( $rev->getId(), 'getId()' ); } public function provideConstructFromRow() { yield 'Full construction' => [ [ - 'rev_id' => '42', - 'rev_page' => '23', + 'rev_id' => '2', + 'rev_page' => '1', 'rev_text_id' => '2', 'rev_timestamp' => '20171017114835', 'rev_user_text' => '127.0.0.1', @@ -209,8 +205,8 @@ class RevisionTest extends MediaWikiTestCase { 'rev_content_model' => 'GOATMODEL', ], function ( RevisionTest $testCase, Revision $rev ) { - $testCase->assertSame( 42, $rev->getId() ); - $testCase->assertSame( 23, $rev->getPage() ); + $testCase->assertSame( 2, $rev->getId() ); + $testCase->assertSame( 1, $rev->getPage() ); $testCase->assertSame( 2, $rev->getTextId() ); $testCase->assertSame( '20171017114835', $rev->getTimestamp() ); $testCase->assertSame( '127.0.0.1', $rev->getUserText() ); @@ -225,10 +221,10 @@ class RevisionTest extends MediaWikiTestCase { $testCase->assertSame( 'GOATMODEL', $rev->getContentModel() ); } ]; - yield 'default field values' => [ + yield 'null fields' => [ [ - 'rev_id' => '42', - 'rev_page' => '23', + 'rev_id' => '2', + 'rev_page' => '1', 'rev_text_id' => '2', 'rev_timestamp' => '20171017114835', 'rev_user_text' => '127.0.0.1', @@ -240,24 +236,11 @@ class RevisionTest extends MediaWikiTestCase { 'rev_comment_cid' => null, ], function ( RevisionTest $testCase, Revision $rev ) { - // 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' ); + $testCase->assertNull( $rev->getSize() ); + $testCase->assertNull( $rev->getParentId() ); + $testCase->assertNull( $rev->getSha1() ); + $testCase->assertSame( 'text/x-wiki', $rev->getContentFormat() ); + $testCase->assertSame( 'wikitext', $rev->getContentModel() ); } ]; } @@ -265,34 +248,11 @@ class RevisionTest extends MediaWikiTestCase { /** * @dataProvider provideConstructFromRow * @covers Revision::__construct - * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow + * @covers Revision::constructFromRowArray */ 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, 0, $this->getMockTitle() ); + $rev = new Revision( $row ); $assertions( $this, $rev ); } @@ -322,7 +282,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getId */ public function testGetId( $rowArray, $expectedId ) { - $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); + $rev = new Revision( $rowArray ); $this->assertEquals( $expectedId, $rev->getId() ); } @@ -336,7 +296,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::setId */ public function testSetId( $input, $expected ) { - $rev = new Revision( [], 0, $this->getMockTitle() ); + $rev = new Revision( [] ); $rev->setId( $input ); $this->assertSame( $expected, $rev->getId() ); } @@ -351,7 +311,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::setUserIdAndName */ public function testSetUserIdAndName( $inputId, $expectedId, $name ) { - $rev = new Revision( [], 0, $this->getMockTitle() ); + $rev = new Revision( [] ); $rev->setUserIdAndName( $inputId, $name ); $this->assertSame( $expectedId, $rev->getUser( Revision::RAW ) ); $this->assertEquals( $name, $rev->getUserText( Revision::RAW ) ); @@ -368,7 +328,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getTextId() */ public function testGetTextId( $rowArray, $expected ) { - $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); + $rev = new Revision( $rowArray ); $this->assertSame( $expected, $rev->getTextId() ); } @@ -383,7 +343,7 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getParentId() */ public function testGetParentId( $rowArray, $expected ) { - $rev = new Revision( $rowArray, 0, $this->getMockTitle() ); + $rev = new Revision( $rowArray ); $this->assertSame( $expected, $rev->getParentId() ); } @@ -416,44 +376,9 @@ 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', @@ -462,7 +387,6 @@ class RevisionTest extends MediaWikiTestCase { ]; yield 'Utf8Legacy' => [ "Wiki est l'\xc3\xa9cole superieur !", - 'fr', 'iso-8859-1', [ 'old_flags' => '', @@ -475,11 +399,8 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText * @dataProvider provideGetRevisionTextWithLegacyEncoding */ - public function testGetRevisionWithLegacyEncoding( $expected, $lang, $encoding, $rowData ) { - $blobStore = $this->getBlobStore(); - $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) ); - $this->setService( 'BlobStore', $blobStore ); - + public function testGetRevisionWithLegacyEncoding( $expected, $encoding, $rowData ) { + $this->setMwGlobals( 'wgLegacyEncoding', $encoding ); $this->testGetRevisionText( $expected, $rowData ); } @@ -491,7 +412,6 @@ class RevisionTest extends MediaWikiTestCase { */ yield 'Utf8NativeGzip' => [ "Wiki est l'\xc3\xa9cole superieur !", - 'fr', 'iso-8859-1', [ 'old_flags' => 'gzip,utf-8', @@ -500,7 +420,6 @@ class RevisionTest extends MediaWikiTestCase { ]; yield 'Utf8LegacyGzip' => [ "Wiki est l'\xc3\xa9cole superieur !", - 'fr', 'iso-8859-1', [ 'old_flags' => 'gzip', @@ -513,13 +432,9 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText * @dataProvider provideGetRevisionTextWithGzipAndLegacyEncoding */ - public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $lang, $encoding, $rowData ) { + public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $encoding, $rowData ) { $this->checkPHPExtension( 'zlib' ); - - $blobStore = $this->getBlobStore(); - $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) ); - $this->setService( 'BlobStore', $blobStore ); - + $this->setMwGlobals( 'wgLegacyEncoding', $encoding ); $this->testGetRevisionText( $expected, $rowData ); } @@ -545,10 +460,7 @@ class RevisionTest extends MediaWikiTestCase { */ public function testCompressRevisionTextUtf8Gzip() { $this->checkPHPExtension( 'zlib' ); - - $blobStore = $this->getBlobStore(); - $blobStore->setCompressBlobs( true ); - $this->setService( 'BlobStore', $blobStore ); + $this->setMwGlobals( 'wgCompressRevisions', true ); $row = new stdClass; $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; @@ -563,41 +475,20 @@ class RevisionTest extends MediaWikiTestCase { Revision::getRevisionText( $row ), "getRevisionText" ); } + public function provideFetchFromConds() { + yield [ 0, [] ]; + yield [ Revision::READ_LOCKING, [ 'FOR UPDATE' ] ]; + } + /** - * @covers Revision::loadFromTitle + * @dataProvider provideFetchFromConds + * @covers Revision::fetchFromConds */ - 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', - ]; + public function testFetchFromConds( $flags, array $options ) { + $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $conditions = [ 'conditionsArray' ]; $db = $this->getMock( IDatabase::class ); - $db->expects( $this->any() ) - ->method( 'getDomainId' ) - ->will( $this->returnValue( wfWikiID() ) ); $db->expects( $this->once() ) ->method( 'selectRow' ) ->with( @@ -606,24 +497,17 @@ class RevisionTest extends MediaWikiTestCase { $this->isType( 'array' ), $this->equalTo( $conditions ), // Method name - $this->stringContains( 'fetchRevisionRowFromConds' ), - // We don't really care about the options here - $this->isType( 'array' ), + $this->equalTo( 'Revision::fetchFromConds' ), + $this->equalTo( $options ), // We don't really care about the join conds are they come from the joinCond methods $this->isType( 'array' ) ) - ->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() ); + ->willReturn( 'RETURNVALUE' ); + + $wrapper = TestingAccessWrapper::newFromClass( Revision::class ); + $result = $wrapper->fetchFromConds( $db, $conditions, $flags ); + + $this->assertEquals( 'RETURNVALUE', $result ); } public function provideDecompressRevisionText() { @@ -688,12 +572,8 @@ class RevisionTest extends MediaWikiTestCase { * @param mixed $expected */ public function testDecompressRevisionText( $legacyEncoding, $text, $flags, $expected ) { - $blobStore = $this->getBlobStore(); - if ( $legacyEncoding ) { - $blobStore->setLegacyEncoding( $legacyEncoding, Language::factory( 'en' ) ); - } - - $this->setService( 'BlobStore', $blobStore ); + $this->setMwGlobals( 'wgLegacyEncoding', $legacyEncoding ); + $this->setMwGlobals( 'wgLanguageCode', 'en' ); $this->assertSame( $expected, Revision::decompressRevisionText( $text, $flags ) @@ -789,20 +669,14 @@ class RevisionTest extends MediaWikiTestCase { * @covers Revision::getRevisionText */ public function testGetRevisionText_external_oldId() { - $cache = $this->getWANObjectCache(); + $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); $this->setService( 'MainWANObjectCache', $cache ); - $this->setService( 'ExternalStoreFactory', new ExternalStoreFactory( [ 'ForTesting' ] ) ); - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - - $blobStore = new SqlBlobStore( $lb, $cache ); - $this->setService( 'BlobStore', $blobStore ); + $cacheKey = $cache->makeKey( 'revisiontext', 'textid', '7777' ); $this->assertSame( 'AAAABBAAA', @@ -814,8 +688,6 @@ class RevisionTest extends MediaWikiTestCase { ] ) ); - - $cacheKey = $cache->makeKey( 'revisiontext', 'textid', 'tt:7777' ); $this->assertSame( 'AAAABBAAA', $cache->get( $cacheKey ) ); } @@ -1011,8 +883,6 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', - 'ar_namespace', - 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1041,8 +911,6 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', - 'ar_namespace', - 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1076,8 +944,6 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', - 'ar_namespace', - 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1114,8 +980,6 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', - 'ar_namespace', - 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1152,8 +1016,6 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'ar_id', 'ar_page_id', - 'ar_namespace', - 'ar_title', 'ar_rev_id', 'ar_text', 'ar_text_id', @@ -1185,11 +1047,6 @@ 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() @@ -1541,11 +1398,6 @@ 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 )