From: jenkins-bot Date: Wed, 7 Mar 2018 10:55:43 +0000 (+0000) Subject: Merge "Add support for 'hu-formal'" X-Git-Tag: 1.31.0-rc.0~430 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=8269ed4dfd5e4395e25945b1fa2ed391684606ed;hp=289e4204ac6e6ab2c86647b1253111a814eb7fae Merge "Add support for 'hu-formal'" --- diff --git a/.gitignore b/.gitignore index bb3a946593..0112cf31a6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ sftp-config.json /images/timeline ## Extension:Score /images/lilypond +## Extension:TimedMediaHandler +/images/transcoded /images/tmp /maintenance/.mweval_history /maintenance/.mwsql_history diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index 746667b686..1f7835d4cc 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -28,6 +28,9 @@ production. as upstream is inactive and has no plans to move to PHP 7. * The old CategorizedRecentChanges feature, including its related configuration option $wgAllowCategorizedRecentChanges, has been removed. +* (T188472) The 'comma' value for $wgArticleCountMethod is no longer supported for + performance reasons, and installations with this setting will now work as if it + was configured with 'any'. === New features in 1.31 === * Wikimedia\Rdbms\IDatabase->select() and similar methods now support @@ -51,6 +54,18 @@ production. * Style tags with a 'data-mw-deduplicate' attribute will be deduplicated as a ParserOutput::getText() post-cache transformation. This may be disabled by passing 'deduplicateStyles' => false to that method. +* The identity of the logged-in or IP "actor" for logged actions is being moved + into a new actor table, with the rows in tables such as revision and logging + referring to the actor ID instead of storing the user ID and name/IP in + every row. + * This is currently gated by $wgActorTableSchemaMigrationStage. Most wikis + can set this to MIGRATION_NEW and run maintenance/migrateActors.php as + soon as any necessary extensions are updated. + * Most code accessing rows for logged actions from the database should use + the relevant getQueryInfo() methods to get the information needed to build + the SQL query. The ActorMigration class may also be used to get feature-flagged + information needed to access actor-related fields during the migration + period. === External library changes in 1.31 === @@ -107,6 +122,7 @@ changes to languages because of Phabricator reports. * (T187824) New language support: Hungarian formal address (hu-formal). === Other changes in 1.31 === +* Browser support for Internet Explorer 10 was lowered from Grade A to Grade C. * Introducing multi-content-revision capability into the storage layer. For details, see . * The Revision class was deprecated in favor of RevisionStore, BlobStore, and @@ -238,8 +254,40 @@ changes to languages because of Phabricator reports. * CommentStore::getCommentLegacy * CommentStore::insert * CommentStore::insertWithTemplate +* The following methods in Title have been renamed, and the old ones are deprecated: + * Title::getSkinFromCssJsSubpage – use ::getSkinFromConfigSubpage + * Title::isCssOrJsPage – use ::isSiteConfigPage + * Title::isCssJsSubpage – use ::isUserConfigPage + * Title::isCssSubpage – use ::isUserCssConfigPage + * Title::isJsSubpage – use ::isUserJsConfigPage +* The following variables and methods in EditPage, deprecated in MediaWiki 1.30, were removed: + * $isCssJsSubpage — use ::isUserConfigPage() + * $isCssSubpage — use ::isUserCssConfigPage() + * $isJsSubpage — use ::isUserJsConfigPage() + * $isWrongCaseCssJsPage – use ::isWrongCaseUserConfigPage() + * ::getSummaryInput() – use ::getSummaryInputWidget() + * ::getSummaryInputOOUI() – use ::getSummaryInputWidget() + * ::getCheckboxes() – use ::getCheckboxesWidget() or ::getCheckboxesDefinition() + * ::getCheckboxesOOUI() – use ::getCheckboxesWidget() or ::getCheckboxesDefinition() * The method ResourceLoaderModule::getPosition(), deprecated in 1.29, has been removed. * The DeferredStringifier class is deprecated, use Message::listParam() instead. +* The type string for the parameter $lang of DateFormatter::getInstance is + deprecated. +* In User, the cookie-related methods which were wrappers for the functions on the response + object, and were deprecated in 1.27, have been removed: + * ::setCookie() + * ::clearCookie() + * ::setExtendedLoginCookie() + Note that User::setCookies() remains, and is not deprecated. +* The global functions wfProfileIn and wfProfileOut, deprecated in 1.25, have been removed. +* The following methods related to caching of half-parsed HTML were deprecated: + * Parser::serializeHalfParsedText() + * Parser::unserializeHalfParsedText() + * Parser::isValidHalfParsedText() + * StripState::getSubState() + * StripState::merge() +* The "free" class is now only applied to unbracketed URLs in wikitext. Links + written using square brackets will get the class "text" not "free". == Compatibility == MediaWiki 1.31 requires PHP 5.5.9 or later. Although HHVM 3.18.5 or later is supported, diff --git a/autoload.php b/autoload.php index 9042f7b808..b5f3e4a067 100644 --- a/autoload.php +++ b/autoload.php @@ -11,6 +11,7 @@ $wgAutoloadLocalClasses = [ 'Action' => __DIR__ . '/includes/actions/Action.php', 'ActiveUsersPager' => __DIR__ . '/includes/specials/pagers/ActiveUsersPager.php', 'ActivityUpdateJob' => __DIR__ . '/includes/jobqueue/jobs/ActivityUpdateJob.php', + 'ActorMigration' => __DIR__ . '/includes/ActorMigration.php', 'AddRFCAndPMIDInterwiki' => __DIR__ . '/maintenance/addRFCandPMIDInterwiki.php', 'AddSite' => __DIR__ . '/maintenance/addSite.php', 'AjaxDispatcher' => __DIR__ . '/includes/AjaxDispatcher.php', @@ -220,6 +221,7 @@ $wgAutoloadLocalClasses = [ 'CachedAction' => __DIR__ . '/includes/actions/CachedAction.php', 'CachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/CachedBagOStuff.php', 'CachingSiteStore' => __DIR__ . '/includes/site/CachingSiteStore.php', + 'CannotCreateActorException' => __DIR__ . '/includes/exception/CannotCreateActorException.php', 'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php', 'CategoriesRdf' => __DIR__ . '/includes/CategoriesRdf.php', 'Category' => __DIR__ . '/includes/Category.php', @@ -647,6 +649,10 @@ $wgAutoloadLocalClasses = [ 'ImportStringSource' => __DIR__ . '/includes/import/ImportStringSource.php', 'ImportTextFiles' => __DIR__ . '/maintenance/importTextFiles.php', 'ImportTitleFactory' => __DIR__ . '/includes/title/ImportTitleFactory.php', + 'ImportableOldRevision' => __DIR__ . '/includes/import/ImportableOldRevision.php', + 'ImportableOldRevisionImporter' => __DIR__ . '/includes/import/ImportableOldRevisionImporter.php', + 'ImportableUploadRevision' => __DIR__ . '/includes/import/ImportableUploadRevision.php', + 'ImportableUploadRevisionImporter' => __DIR__ . '/includes/import/ImportableUploadRevisionImporter.php', 'IncludableSpecialPage' => __DIR__ . '/includes/specialpage/IncludableSpecialPage.php', 'IndexPager' => __DIR__ . '/includes/pager/IndexPager.php', 'InfoAction' => __DIR__ . '/includes/actions/InfoAction.php', @@ -947,6 +953,8 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Storage\\IncompleteRevisionException' => __DIR__ . '/includes/Storage/IncompleteRevisionException.php', 'MediaWiki\\Storage\\MutableRevisionRecord' => __DIR__ . '/includes/Storage/MutableRevisionRecord.php', 'MediaWiki\\Storage\\MutableRevisionSlots' => __DIR__ . '/includes/Storage/MutableRevisionSlots.php', + 'MediaWiki\\Storage\\NameTableAccessException' => __DIR__ . '/includes/Storage/NameTableAccessException.php', + 'MediaWiki\\Storage\\NameTableStore' => __DIR__ . '/includes/Storage/NameTableStore.php', 'MediaWiki\\Storage\\RevisionAccessException' => __DIR__ . '/includes/Storage/RevisionAccessException.php', 'MediaWiki\\Storage\\RevisionArchiveRecord' => __DIR__ . '/includes/Storage/RevisionArchiveRecord.php', 'MediaWiki\\Storage\\RevisionFactory' => __DIR__ . '/includes/Storage/RevisionFactory.php', @@ -995,6 +1003,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Widget\\Search\\SimpleSearchResultSetWidget' => __DIR__ . '/includes/widget/search/SimpleSearchResultSetWidget.php', 'MediaWiki\\Widget\\Search\\SimpleSearchResultWidget' => __DIR__ . '/includes/widget/search/SimpleSearchResultWidget.php', 'MediaWiki\\Widget\\SelectWithInputWidget' => __DIR__ . '/includes/widget/SelectWithInputWidget.php', + 'MediaWiki\\Widget\\SizeFilterWidget' => __DIR__ . '/includes/widget/SizeFilterWidget.php', 'MediaWiki\\Widget\\TitleInputWidget' => __DIR__ . '/includes/widget/TitleInputWidget.php', 'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php', 'MediaWiki\\Widget\\UsersMultiselectWidget' => __DIR__ . '/includes/widget/UsersMultiselectWidget.php', @@ -1016,6 +1025,7 @@ $wgAutoloadLocalClasses = [ 'MessageContent' => __DIR__ . '/includes/content/MessageContent.php', 'MessageLocalizer' => __DIR__ . '/languages/MessageLocalizer.php', 'MessageSpecifier' => __DIR__ . '/includes/libs/MessageSpecifier.php', + 'MigrateActors' => __DIR__ . '/maintenance/migrateActors.php', 'MigrateArchiveText' => __DIR__ . '/maintenance/migrateArchiveText.php', 'MigrateComments' => __DIR__ . '/maintenance/migrateComments.php', 'MigrateFileRepoLayout' => __DIR__ . '/maintenance/migrateFileRepoLayout.php', @@ -1075,6 +1085,7 @@ $wgAutoloadLocalClasses = [ 'ObjectFactory' => __DIR__ . '/includes/compat/ObjectFactory.php', 'OldChangesList' => __DIR__ . '/includes/changes/OldChangesList.php', 'OldLocalFile' => __DIR__ . '/includes/filerepo/file/OldLocalFile.php', + 'OldRevisionImporter' => __DIR__ . '/includes/import/OldRevisionImporter.php', 'OracleInstaller' => __DIR__ . '/includes/installer/OracleInstaller.php', 'OracleUpdater' => __DIR__ . '/includes/installer/OracleUpdater.php', 'OrderedStreamingForkController' => __DIR__ . '/includes/OrderedStreamingForkController.php', @@ -1570,6 +1581,7 @@ $wgAutoloadLocalClasses = [ 'UploadFromStash' => __DIR__ . '/includes/upload/UploadFromStash.php', 'UploadFromUrl' => __DIR__ . '/includes/upload/UploadFromUrl.php', 'UploadLogFormatter' => __DIR__ . '/includes/logging/UploadLogFormatter.php', + 'UploadRevisionImporter' => __DIR__ . '/includes/import/UploadRevisionImporter.php', 'UploadSourceAdapter' => __DIR__ . '/includes/import/UploadSourceAdapter.php', 'UploadSourceField' => __DIR__ . '/includes/specials/formfields/UploadSourceField.php', 'UploadStash' => __DIR__ . '/includes/upload/UploadStash.php', @@ -1718,6 +1730,7 @@ $wgAutoloadLocalClasses = [ 'Wikimedia\\Rdbms\\TransactionProfiler' => __DIR__ . '/includes/libs/rdbms/TransactionProfiler.php', 'WikitextContent' => __DIR__ . '/includes/content/WikitextContent.php', 'WikitextContentHandler' => __DIR__ . '/includes/content/WikitextContentHandler.php', + 'WikitextLogFormatter' => __DIR__ . '/includes/logging/WikitextLogFormatter.php', 'WinCacheBagOStuff' => __DIR__ . '/includes/libs/objectcache/WinCacheBagOStuff.php', 'WithoutInterwikiPage' => __DIR__ . '/includes/specials/SpecialWithoutinterwiki.php', 'WordLevelDiff' => __DIR__ . '/includes/diff/WordLevelDiff.php', diff --git a/includes/ActorMigration.php b/includes/ActorMigration.php new file mode 100644 index 0000000000..161c7a923b --- /dev/null +++ b/includes/ActorMigration.php @@ -0,0 +1,383 @@ + [ + 'table' => 'revision_actor_temp', + 'pk' => 'revactor_rev', + 'field' => 'revactor_actor', + 'joinPK' => 'rev_id', + 'extra' => [ + 'revactor_timestamp' => 'rev_timestamp', + 'revactor_page' => 'rev_page', + ], + ], + ]; + + /** + * Fields that formerly used $tempTables + * @var array Key is '$key', value is the MediaWiki version in which it was + * removed from $tempTables. + */ + private static $formerTempTables = []; + + /** + * Define fields that use non-standard mapping + * @var array Keys are the user id column name, values are arrays with two + * elements (the user text column name and the actor id column name) + */ + private static $specialFields = [ + 'ipb_by' => [ 'ipb_by_text', 'ipb_by_actor' ], + ]; + + /** @var array|null Cache for `self::getJoin()` */ + private $joinCache = null; + + /** @var int One of the MIGRATION_* constants */ + private $stage; + + /** @private */ + public function __construct( $stage ) { + $this->stage = $stage; + } + + /** + * Static constructor + * @return ActorMigration + */ + public static function newMigration() { + return MediaWikiServices::getInstance()->getActorMigration(); + } + + /** + * Return an SQL condition to test if a user field is anonymous + * @param string $field Field name or SQL fragment + * @return string + */ + public function isAnon( $field ) { + return $this->stage === MIGRATION_NEW ? "$field IS NULL" : "$field = 0"; + } + + /** + * Return an SQL condition to test if a user field is non-anonymous + * @param string $field Field name or SQL fragment + * @return string + */ + public function isNotAnon( $field ) { + return $this->stage === MIGRATION_NEW ? "$field IS NOT NULL" : "$field != 0"; + } + + /** + * @param string $key A key such as "rev_user" identifying the actor + * field being fetched. + * @return string[] [ $text, $actor ] + */ + private static function getFieldNames( $key ) { + if ( isset( self::$specialFields[$key] ) ) { + return self::$specialFields[$key]; + } + + return [ $key . '_text', substr( $key, 0, -5 ) . '_actor' ]; + } + + /** + * Get SELECT fields and joins for the actor key + * + * @param string $key A key such as "rev_user" identifying the actor + * field being fetched. + * @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()` + * All tables, fields, and joins are aliased, so `+` is safe to use. + */ + public function getJoin( $key ) { + if ( !isset( $this->joinCache[$key] ) ) { + $tables = []; + $fields = []; + $joins = []; + + list( $text, $actor ) = self::getFieldNames( $key ); + + if ( $this->stage === MIGRATION_OLD ) { + $fields[$key] = $key; + $fields[$text] = $text; + $fields[$actor] = 'NULL'; + } else { + $join = $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN'; + + if ( isset( self::$tempTables[$key] ) ) { + $t = self::$tempTables[$key]; + $alias = "temp_$key"; + $tables[$alias] = $t['table']; + $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ]; + $joinField = "{$alias}.{$t['field']}"; + } else { + $joinField = $actor; + } + + $alias = "actor_$key"; + $tables[$alias] = 'actor'; + $joins[$alias] = [ $join, "{$alias}.actor_id = {$joinField}" ]; + + if ( $this->stage === MIGRATION_NEW ) { + $fields[$key] = "{$alias}.actor_user"; + $fields[$text] = "{$alias}.actor_name"; + } else { + $fields[$key] = "COALESCE( {$alias}.actor_user, $key )"; + $fields[$text] = "COALESCE( {$alias}.actor_name, $text )"; + } + $fields[$actor] = $joinField; + } + + $this->joinCache[$key] = [ + 'tables' => $tables, + 'fields' => $fields, + 'joins' => $joins, + ]; + } + + return $this->joinCache[$key]; + } + + /** + * Get UPDATE fields for the actor + * + * @param IDatabase $dbw Database to use for creating an actor ID, if necessary + * @param string $key A key such as "rev_user" identifying the actor + * field being fetched. + * @param UserIdentity $user User to set in the update + * @return array to merge into `$values` to `IDatabase->update()` or `$a` to `IDatabase->insert()` + */ + public function getInsertValues( IDatabase $dbw, $key, UserIdentity $user ) { + if ( isset( self::$tempTables[$key] ) ) { + throw new InvalidArgumentException( "Must use getInsertValuesWithTempTable() for $key" ); + } + + list( $text, $actor ) = self::getFieldNames( $key ); + $ret = []; + if ( $this->stage <= MIGRATION_WRITE_BOTH ) { + $ret[$key] = $user->getId(); + $ret[$text] = $user->getName(); + } + if ( $this->stage >= MIGRATION_WRITE_BOTH ) { + // We need to be able to assign an actor ID if none exists + if ( !$user instanceof User && !$user->getActorId() ) { + $user = User::newFromAnyId( $user->getId(), $user->getName(), null ); + } + $ret[$actor] = $user->getActorId( $dbw ); + } + return $ret; + } + + /** + * Get UPDATE fields for the actor + * + * @param IDatabase $dbw Database to use for creating an actor ID, if necessary + * @param string $key A key such as "rev_user" identifying the actor + * field being fetched. + * @param UserIdentity $user User to set in the update + * @return array with two values: + * - array to merge into `$values` to `IDatabase->update()` or `$a` to `IDatabase->insert()` + * - callback to call with the the primary key for the main table insert + * and extra fields needed for the temp table. + */ + public function getInsertValuesWithTempTable( IDatabase $dbw, $key, UserIdentity $user ) { + if ( isset( self::$formerTempTables[$key] ) ) { + wfDeprecated( __METHOD__ . " for $key", self::$formerTempTables[$key] ); + } elseif ( !isset( self::$tempTables[$key] ) ) { + throw new InvalidArgumentException( "Must use getInsertValues() for $key" ); + } + + list( $text, $actor ) = self::getFieldNames( $key ); + $ret = []; + $callback = null; + if ( $this->stage <= MIGRATION_WRITE_BOTH ) { + $ret[$key] = $user->getId(); + $ret[$text] = $user->getName(); + } + if ( $this->stage >= MIGRATION_WRITE_BOTH ) { + // We need to be able to assign an actor ID if none exists + if ( !$user instanceof User && !$user->getActorId() ) { + $user = User::newFromAnyId( $user->getId(), $user->getName(), null ); + } + $id = $user->getActorId( $dbw ); + + if ( isset( self::$tempTables[$key] ) ) { + $func = __METHOD__; + $callback = function ( $pk, array $extra ) use ( $dbw, $key, $id, $func ) { + $t = self::$tempTables[$key]; + $set = [ $t['field'] => $id ]; + foreach ( $t['extra'] as $to => $from ) { + if ( !array_key_exists( $from, $extra ) ) { + throw new InvalidArgumentException( "$func callback: \$extra[$from] is not provided" ); + } + $set[$to] = $extra[$from]; + } + $dbw->upsert( + $t['table'], + [ $t['pk'] => $pk ] + $set, + [ $t['pk'] ], + $set, + $func + ); + }; + } else { + $ret[$actor] = $id; + $callback = function ( $pk, array $extra ) { + }; + } + } elseif ( isset( self::$tempTables[$key] ) ) { + $func = __METHOD__; + $callback = function ( $pk, array $extra ) use ( $key, $func ) { + $t = self::$tempTables[$key]; + foreach ( $t['extra'] as $to => $from ) { + if ( !array_key_exists( $from, $extra ) ) { + throw new InvalidArgumentException( "$func callback: \$extra[$from] is not provided" ); + } + } + }; + } else { + $callback = function ( $pk, array $extra ) { + }; + } + return [ $ret, $callback ]; + } + + /** + * Get WHERE condition for the actor + * + * @param IDatabase $db Database to use for quoting and list-making + * @param string $key A key such as "rev_user" identifying the actor + * field being fetched. + * @param UserIdentity|UserIdentity[] $users Users to test for + * @param bool $useId If false, don't try to query by the user ID. + * Intended for use with rc_user since it has an index on + * (rc_user_text,rc_timestamp) but not (rc_user,rc_timestamp). + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - conds: (string) to include in the `$cond` to `IDatabase->select()` + * - orconds: (array[]) array of alternatives in case a union of multiple + * queries would be more efficient than a query with OR. May have keys + * 'actor', 'userid', 'username'. + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + * All tables and joins are aliased, so `+` is safe to use. + */ + public function getWhere( IDatabase $db, $key, $users, $useId = true ) { + $tables = []; + $conds = []; + $joins = []; + + if ( $users instanceof UserIdentity ) { + $users = [ $users ]; + } + + // Get information about all the passed users + $ids = []; + $names = []; + $actors = []; + foreach ( $users as $user ) { + if ( $useId && $user->getId() ) { + $ids[] = $user->getId(); + } else { + $names[] = $user->getName(); + } + $actorId = $user->getActorId(); + if ( $actorId ) { + $actors[] = $actorId; + } + } + + list( $text, $actor ) = self::getFieldNames( $key ); + + // Combine data into conditions to be ORed together + $actorNotEmpty = []; + if ( $this->stage === MIGRATION_OLD ) { + $actors = []; + $actorEmpty = []; + } elseif ( isset( self::$tempTables[$key] ) ) { + $t = self::$tempTables[$key]; + $alias = "temp_$key"; + $tables[$alias] = $t['table']; + $joins[$alias] = [ + $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN', + "{$alias}.{$t['pk']} = {$t['joinPK']}" + ]; + $joinField = "{$alias}.{$t['field']}"; + $actorEmpty = [ $joinField => null ]; + if ( $this->stage !== MIGRATION_NEW ) { + // Otherwise the resulting test can evaluate to NULL, and + // NOT(NULL) is NULL rather than true. + $actorNotEmpty = [ "$joinField IS NOT NULL" ]; + } + } else { + $joinField = $actor; + $actorEmpty = [ $joinField => 0 ]; + } + + if ( $actors ) { + $conds['actor'] = $db->makeList( + $actorNotEmpty + [ $joinField => $actors ], IDatabase::LIST_AND + ); + } + if ( $this->stage < MIGRATION_NEW && $ids ) { + $conds['userid'] = $db->makeList( + $actorEmpty + [ $key => $ids ], IDatabase::LIST_AND + ); + } + if ( $this->stage < MIGRATION_NEW && $names ) { + $conds['username'] = $db->makeList( + $actorEmpty + [ $text => $names ], IDatabase::LIST_AND + ); + } + + return [ + 'tables' => $tables, + 'conds' => $conds ? $db->makeList( array_values( $conds ), IDatabase::LIST_OR ) : '1=0', + 'orconds' => $conds, + 'joins' => $joins, + ]; + } + +} diff --git a/includes/Block.php b/includes/Block.php index bdc6702f91..4e878d1045 100644 --- a/includes/Block.php +++ b/includes/Block.php @@ -206,12 +206,25 @@ class Block { * @return array */ public static function selectFields() { + global $wgActorTableSchemaMigrationStage; + + if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) { + // If code is using this instead of self::getQueryInfo(), there's a + // decent chance it's going to try to directly access + // $row->ipb_by or $row->ipb_by_text and we can't give it + // useful values here once those aren't being written anymore. + throw new BadMethodCallException( + 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH' + ); + } + wfDeprecated( __METHOD__, '1.31' ); return [ 'ipb_id', 'ipb_address', 'ipb_by', 'ipb_by_text', + 'ipb_by_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'ipb_by_actor' : null, 'ipb_timestamp', 'ipb_auto', 'ipb_anon_only', @@ -236,13 +249,12 @@ class Block { */ public static function getQueryInfo() { $commentQuery = CommentStore::getStore()->getJoin( 'ipb_reason' ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'ipb_by' ); return [ - 'tables' => [ 'ipblocks' ] + $commentQuery['tables'], + 'tables' => [ 'ipblocks' ] + $commentQuery['tables'] + $actorQuery['tables'], 'fields' => [ 'ipb_id', 'ipb_address', - 'ipb_by', - 'ipb_by_text', 'ipb_timestamp', 'ipb_auto', 'ipb_anon_only', @@ -253,8 +265,8 @@ class Block { 'ipb_block_email', 'ipb_allow_usertalk', 'ipb_parent_block_id', - ] + $commentQuery['fields'], - 'joins' => $commentQuery['joins'], + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'joins' => $commentQuery['joins'] + $actorQuery['joins'], ]; } @@ -445,11 +457,9 @@ class Block { */ protected function initFromRow( $row ) { $this->setTarget( $row->ipb_address ); - if ( $row->ipb_by ) { // local user - $this->setBlocker( User::newFromId( $row->ipb_by ) ); - } else { // foreign user - $this->setBlocker( $row->ipb_by_text ); - } + $this->setBlocker( User::newFromAnyId( + $row->ipb_by, $row->ipb_by_text, isset( $row->ipb_by_actor ) ? $row->ipb_by_actor : null + ) ); $this->mTimestamp = wfTimestamp( TS_MW, $row->ipb_timestamp ); $this->mAuto = $row->ipb_auto; @@ -519,6 +529,9 @@ class Block { if ( $this->getSystemBlockType() !== null ) { throw new MWException( 'Cannot insert a system block into the database' ); } + if ( !$this->getBlocker() || $this->getBlocker()->getName() === '' ) { + throw new MWException( 'Cannot insert a block without a blocker set' ); + } wfDebug( "Block::insert; timestamp {$this->mTimestamp}\n" ); @@ -526,10 +539,7 @@ class Block { $dbw = wfGetDB( DB_MASTER ); } - # Periodic purge via commit hooks - if ( mt_rand( 0, 9 ) == 0 ) { - self::purgeExpired(); - } + self::purgeExpired(); $row = $this->getDatabaseArray( $dbw ); @@ -640,8 +650,6 @@ class Block { $a = [ 'ipb_address' => (string)$this->target, 'ipb_user' => $uid, - 'ipb_by' => $this->getBy(), - 'ipb_by_text' => $this->getByName(), 'ipb_timestamp' => $dbw->timestamp( $this->mTimestamp ), 'ipb_auto' => $this->mAuto, 'ipb_anon_only' => !$this->isHardblock(), @@ -654,7 +662,8 @@ class Block { 'ipb_block_email' => $this->prevents( 'sendemail' ), 'ipb_allow_usertalk' => !$this->prevents( 'editownusertalk' ), 'ipb_parent_block_id' => $this->mParentBlockId - ] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->mReason ); + ] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->mReason ) + + ActorMigration::newMigration()->getInsertValues( $dbw, 'ipb_by', $this->getBlocker() ); return $a; } @@ -665,12 +674,11 @@ class Block { */ protected function getAutoblockUpdateArray( IDatabase $dbw ) { return [ - 'ipb_by' => $this->getBy(), - 'ipb_by_text' => $this->getByName(), 'ipb_create_account' => $this->prevents( 'createaccount' ), 'ipb_deleted' => (int)$this->mHideName, // typecast required for SQLite 'ipb_allow_usertalk' => !$this->prevents( 'editownusertalk' ), - ] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->mReason ); + ] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->mReason ) + + ActorMigration::newMigration()->getInsertValues( $dbw, 'ipb_by', $this->getBlocker() ); } /** @@ -710,16 +718,27 @@ class Block { return; } + $target = $block->getTarget(); + if ( is_string( $target ) ) { + $target = User::newFromName( $target, false ); + } + $dbr = wfGetDB( DB_REPLICA ); + $rcQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $target, false ); $options = [ 'ORDER BY' => 'rc_timestamp DESC' ]; - $conds = [ 'rc_user_text' => (string)$block->getTarget() ]; // Just the last IP used. $options['LIMIT'] = 1; - $res = $dbr->select( 'recentchanges', [ 'rc_ip' ], $conds, - __METHOD__, $options ); + $res = $dbr->select( + [ 'recentchanges' ] + $rcQuery['tables'], + [ 'rc_ip' ], + $rcQuery['conds'], + __METHOD__, + $options, + $rcQuery['joins'] + ); if ( !$res->numRows() ) { # No results, don't autoblock anything @@ -1119,11 +1138,14 @@ class Block { wfGetDB( DB_MASTER ), __METHOD__, function ( IDatabase $dbw, $fname ) { - $dbw->delete( - 'ipblocks', + $ids = $dbw->selectFieldValues( 'ipblocks', + 'ipb_id', [ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ], $fname ); + if ( $ids ) { + $dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], $fname ); + } } ) ); } @@ -1471,7 +1493,7 @@ class Block { /** * Get the user who implemented this block - * @return User|string Local User object or string for a foreign user + * @return User User object. May name a foreign user. */ public function getBlocker() { return $this->blocker; diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index ae5cef5e1d..fad49e48c8 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -3311,9 +3311,10 @@ $wgAllowUserJs = false; $wgAllowUserCss = false; /** - * Allow user-preferences implemented in CSS? - * This allows users to customise the site appearance to a greater - * degree; disabling it will improve page load times. + * Allow style-related user-preferences? + * + * This controls whether the `editfont` and `underline` preferences + * are availabe to users. */ $wgAllowUserCssPrefs = true; @@ -4438,7 +4439,6 @@ $wgEnableMagicLinks = [ * * This variable can have the following values: * - 'any': all pages as considered as valid articles - * - 'comma': the page must contain a comma to be considered valid * - 'link': the page must contain a [[wiki link]] to be considered valid * * See also See https://www.mediawiki.org/wiki/Manual:Article_count @@ -5127,8 +5127,6 @@ $wgGroupPermissions['*']['edit'] = true; $wgGroupPermissions['*']['createpage'] = true; $wgGroupPermissions['*']['createtalk'] = true; $wgGroupPermissions['*']['writeapi'] = true; -$wgGroupPermissions['*']['editmyusercss'] = true; -$wgGroupPermissions['*']['editmyuserjs'] = true; $wgGroupPermissions['*']['viewmywatchlist'] = true; $wgGroupPermissions['*']['editmywatchlist'] = true; $wgGroupPermissions['*']['viewmyprivateinfo'] = true; @@ -5151,6 +5149,8 @@ $wgGroupPermissions['user']['upload'] = true; $wgGroupPermissions['user']['reupload'] = true; $wgGroupPermissions['user']['reupload-shared'] = true; $wgGroupPermissions['user']['minoredit'] = true; +$wgGroupPermissions['user']['editmyusercss'] = true; +$wgGroupPermissions['user']['editmyuserjs'] = true; $wgGroupPermissions['user']['purge'] = true; $wgGroupPermissions['user']['sendemail'] = true; $wgGroupPermissions['user']['applychangetags'] = true; @@ -5813,6 +5813,7 @@ $wgGrantPermissions['editpage']['changetags'] = true; $wgGrantPermissions['editprotected'] = $wgGrantPermissions['editpage']; $wgGrantPermissions['editprotected']['editprotected'] = true; +// FIXME: Rename editmycssjs to editmyconfig $wgGrantPermissions['editmycssjs'] = $wgGrantPermissions['editpage']; $wgGrantPermissions['editmycssjs']['editmyusercss'] = true; $wgGrantPermissions['editmycssjs']['editmyuserjs'] = true; @@ -8829,6 +8830,13 @@ $wgInterwikiPrefixDisplayTypes = []; */ $wgCommentTableSchemaMigrationStage = MIGRATION_OLD; +/** + * Actor table schema migration stage. + * @since 1.31 + * @var int One of the MIGRATION_* constants + */ +$wgActorTableSchemaMigrationStage = MIGRATION_OLD; + /** * For really cool vim folding this needs to be at the end: * vim: foldmarker=@{,@} foldmethod=marker diff --git a/includes/EditPage.php b/includes/EditPage.php index f9c7fb29e8..ad5f75dd0a 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -238,30 +238,6 @@ class EditPage { /** @var bool */ public $isConflict = false; - /** - * @deprecated since 1.30 use Title::isCssJsSubpage() - * @var bool - */ - public $isCssJsSubpage = false; - - /** - * @deprecated since 1.30 use Title::isCssSubpage() - * @var bool - */ - public $isCssSubpage = false; - - /** - * @deprecated since 1.30 use Title::isJsSubpage() - * @var bool - */ - public $isJsSubpage = false; - - /** - * @deprecated since 1.30 - * @var bool - */ - public $isWrongCaseCssJsPage = false; - /** @var bool New page or new section */ public $isNew = false; @@ -660,13 +636,6 @@ class EditPage { } $this->isConflict = false; - // css / js subpages of user pages get a special treatment - // The following member variables are deprecated since 1.30, - // the functions should be used instead. - $this->isCssJsSubpage = $this->mTitle->isCssJsSubpage(); - $this->isCssSubpage = $this->mTitle->isCssSubpage(); - $this->isJsSubpage = $this->mTitle->isJsSubpage(); - $this->isWrongCaseCssJsPage = $this->isWrongCaseCssJsPage(); # Show applicable editing introductions if ( $this->formtype == 'initial' || $this->firsttime ) { @@ -877,9 +846,9 @@ class EditPage { * * @return bool */ - protected function isWrongCaseCssJsPage() { - if ( $this->mTitle->isCssJsSubpage() ) { - $name = $this->mTitle->getSkinFromCssJsSubpage(); + protected function isWrongCaseUserConfigPage() { + if ( $this->mTitle->isUserConfigPage() ) { + $name = $this->mTitle->getSkinFromConfigSubpage(); $skins = array_merge( array_keys( Skin::getSkinNames() ), [ 'common' ] @@ -2879,7 +2848,7 @@ ERROR; $out->addHTML( $editConflictHelper->getEditFormHtmlBeforeContent() ); } - if ( !$this->mTitle->isCssJsSubpage() && $showToolbar && $user->getOption( 'showtoolbar' ) ) { + if ( !$this->mTitle->isUserConfigPage() && $showToolbar && $user->getOption( 'showtoolbar' ) ) { $out->addHTML( self::getEditToolbar( $this->mTitle ) ); } @@ -3116,22 +3085,26 @@ ERROR; ); } } else { - if ( $this->mTitle->isCssJsSubpage() ) { + if ( $this->mTitle->isUserConfigPage() ) { # Check the skin exists - if ( $this->isWrongCaseCssJsPage() ) { + if ( $this->isWrongCaseUserConfigPage() ) { $out->wrapWikiMsg( - "
\n$1\n
", - [ 'userinvalidcssjstitle', $this->mTitle->getSkinFromCssJsSubpage() ] + "
\n$1\n
", + [ 'userinvalidconfigtitle', $this->mTitle->getSkinFromConfigSubpage() ] ); } if ( $this->getTitle()->isSubpageOf( $user->getUserPage() ) ) { - $isCssSubpage = $this->mTitle->isCssSubpage(); - $out->wrapWikiMsg( '
$1
', - $isCssSubpage ? 'usercssispublic' : 'userjsispublic' - ); + $isUserCssConfig = $this->mTitle->isUserCssConfigPage(); + + $warning = $isUserCssConfig + ? 'usercssispublic' + : 'userjsispublic'; + + $out->wrapWikiMsg( '
$1
', $warning ); + if ( $this->formtype !== 'preview' ) { $config = $this->context->getConfig(); - if ( $isCssSubpage && $config->get( 'AllowUserCss' ) ) { + if ( $isUserCssConfig && $config->get( 'AllowUserCss' ) ) { $out->wrapWikiMsg( "
\n$1\n
", [ 'usercssyoucanpreview' ] @@ -3165,73 +3138,21 @@ ERROR; * @return array */ private function getSummaryInputAttributes( array $inputAttrs = null ) { - // Note: the maxlength is overridden in JS to 255 and to make it use UTF-8 bytes, not characters. + $conf = $this->context->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP + // (e.g. emojis) count for two each. This limit is overridden in JS to instead count + // Unicode codepoints (or 255 UTF-8 bytes for old schema). return ( is_array( $inputAttrs ) ? $inputAttrs : [] ) + [ 'id' => 'wpSummary', 'name' => 'wpSummary', - 'maxlength' => '200', + 'maxlength' => $oldCommentSchema ? 200 : CommentStore::COMMENT_CHARACTER_LIMIT, 'tabindex' => 1, 'size' => 60, 'spellcheck' => 'true', ]; } - /** - * Standard summary input and label (wgSummary), abstracted so EditPage - * subclasses may reorganize the form. - * Note that you do not need to worry about the label's for=, it will be - * inferred by the id given to the input. You can remove them both by - * passing [ 'id' => false ] to $userInputAttrs. - * - * @deprecated since 1.30 Use getSummaryInputWidget() instead - * @param string $summary The value of the summary input - * @param string $labelText The html to place inside the label - * @param array $inputAttrs Array of attrs to use on the input - * @param array $spanLabelAttrs Array of attrs to use on the span inside the label - * @return array An array in the format [ $label, $input ] - */ - public function getSummaryInput( $summary = "", $labelText = null, - $inputAttrs = null, $spanLabelAttrs = null - ) { - wfDeprecated( __METHOD__, '1.30' ); - $inputAttrs = $this->getSummaryInputAttributes( $inputAttrs ); - $inputAttrs += Linker::tooltipAndAccesskeyAttribs( 'summary' ); - - $spanLabelAttrs = ( is_array( $spanLabelAttrs ) ? $spanLabelAttrs : [] ) + [ - 'class' => $this->missingSummary ? 'mw-summarymissed' : 'mw-summary', - 'id' => "wpSummaryLabel" - ]; - - $label = null; - if ( $labelText ) { - $label = Xml::tags( - 'label', - $inputAttrs['id'] ? [ 'for' => $inputAttrs['id'] ] : null, - $labelText - ); - $label = Xml::tags( 'span', $spanLabelAttrs, $label ); - } - - $input = Html::input( 'wpSummary', $summary, 'text', $inputAttrs ); - - return [ $label, $input ]; - } - - /** - * Builds a standard summary input with a label. - * - * @deprecated since 1.30 Use getSummaryInputWidget() instead - * @param string $summary The value of the summary input - * @param string $labelText The html to place inside the label - * @param array $inputAttrs Array of attrs to use on the input - * - * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input - */ - function getSummaryInputOOUI( $summary = "", $labelText = null, $inputAttrs = null ) { - wfDeprecated( __METHOD__, '1.30' ); - return $this->getSummaryInputWidget( $summary, $labelText, $inputAttrs ); - } - /** * Builds a standard summary input with a label. * @@ -3702,7 +3623,7 @@ ERROR; $out->addHTML( $this->editFormTextAfterWarn ); $out->addHTML( "
\n" ); - $out->addHTML( implode( $this->getEditButtons( $tabindex ), "\n" ) . "\n" ); + $out->addHTML( implode( "\n", $this->getEditButtons( $tabindex ) ) . "\n" ); $cancel = $this->getCancelLink(); @@ -3812,30 +3733,30 @@ ERROR; protected function getLastDelete() { $dbr = wfGetDB( DB_REPLICA ); $commentQuery = CommentStore::getStore()->getJoin( 'log_comment' ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); $data = $dbr->selectRow( - [ 'logging', 'user' ] + $commentQuery['tables'], + array_merge( [ 'logging' ], $commentQuery['tables'], $actorQuery['tables'], [ 'user' ] ), [ 'log_type', 'log_action', 'log_timestamp', - 'log_user', 'log_namespace', 'log_title', 'log_params', 'log_deleted', 'user_name' - ] + $commentQuery['fields'], [ + ] + $commentQuery['fields'] + $actorQuery['fields'], + [ 'log_namespace' => $this->mTitle->getNamespace(), 'log_title' => $this->mTitle->getDBkey(), 'log_type' => 'delete', 'log_action' => 'delete', - 'user_id=log_user' ], __METHOD__, [ 'LIMIT' => 1, 'ORDER BY' => 'log_timestamp DESC' ], [ - 'user' => [ 'JOIN', 'user_id=log_user' ], - ] + $commentQuery['joins'] + 'user' => [ 'JOIN', 'user_id=' . $actorQuery['fields']['log_user'] ], + ] + $commentQuery['joins'] + $actorQuery['joins'] ); // Quick paranoid permission checks... if ( is_object( $data ) ) { @@ -3913,10 +3834,10 @@ ERROR; } # don't parse non-wikitext pages, show message about preview - if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) { - if ( $this->mTitle->isCssJsSubpage() ) { + if ( $this->mTitle->isUserConfigPage() || $this->mTitle->isSiteConfigPage() ) { + if ( $this->mTitle->isUserConfigPage() ) { $level = 'user'; - } elseif ( $this->mTitle->isCssOrJsPage() ) { + } elseif ( $this->mTitle->isSiteConfigPage() ) { $level = 'site'; } else { $level = false; @@ -4248,76 +4169,6 @@ ERROR; return $checkboxes; } - /** - * Returns an array of html code of the following checkboxes old style: - * minor and watch - * - * @deprecated since 1.30 Use getCheckboxesWidget() or getCheckboxesDefinition() instead - * @param int &$tabindex Current tabindex - * @param array $checked See getCheckboxesDefinition() - * @return array - */ - public function getCheckboxes( &$tabindex, $checked ) { - wfDeprecated( __METHOD__, '1.30' ); - $checkboxes = []; - $checkboxesDef = $this->getCheckboxesDefinition( $checked ); - - // Backwards-compatibility for the EditPageBeforeEditChecks hook - if ( !$this->isNew ) { - $checkboxes['minor'] = ''; - } - $checkboxes['watch'] = ''; - - foreach ( $checkboxesDef as $name => $options ) { - $legacyName = isset( $options['legacy-name'] ) ? $options['legacy-name'] : $name; - $label = $this->context->msg( $options['label-message'] )->parse(); - $attribs = [ - 'tabindex' => ++$tabindex, - 'id' => $options['id'], - ]; - $labelAttribs = [ - 'for' => $options['id'], - ]; - if ( isset( $options['tooltip'] ) ) { - $attribs['accesskey'] = $this->context->msg( "accesskey-{$options['tooltip']}" )->text(); - $labelAttribs['title'] = Linker::titleAttrib( $options['tooltip'], 'withaccess' ); - } - if ( isset( $options['title-message'] ) ) { - $labelAttribs['title'] = $this->context->msg( $options['title-message'] )->text(); - } - if ( isset( $options['label-id'] ) ) { - $labelAttribs['id'] = $options['label-id']; - } - $checkboxHtml = - Xml::check( $name, $options['default'], $attribs ) . - ' ' . - Xml::tags( 'label', $labelAttribs, $label ); - - $checkboxes[ $legacyName ] = $checkboxHtml; - } - - // Avoid PHP 7.1 warning of passing $this by reference - $editPage = $this; - Hooks::run( 'EditPageBeforeEditChecks', [ &$editPage, &$checkboxes, &$tabindex ], '1.29' ); - return $checkboxes; - } - - /** - * Returns an array of checkboxes for the edit form, including 'minor' and 'watch' checkboxes and - * any other added by extensions. - * - * @deprecated since 1.30 Use getCheckboxesWidget() or getCheckboxesDefinition() instead - * @param int &$tabindex Current tabindex - * @param array $checked Array of checkbox => bool, where bool indicates the checked - * status of the checkbox - * - * @return array Associative array of string keys to OOUI\FieldLayout instances - */ - public function getCheckboxesOOUI( &$tabindex, $checked ) { - wfDeprecated( __METHOD__, '1.30' ); - return $this->getCheckboxesWidget( $tabindex, $checked ); - } - /** * Returns an array of checkboxes for the edit form, including 'minor' and 'watch' checkboxes and * any other added by extensions. diff --git a/includes/FileDeleteForm.php b/includes/FileDeleteForm.php index 8c843c4438..783de1c0c4 100644 --- a/includes/FileDeleteForm.php +++ b/includes/FileDeleteForm.php @@ -246,6 +246,9 @@ class FileDeleteForm { private function showForm() { global $wgOut, $wgUser, $wgRequest; + $conf = RequestContext::getMain()->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + if ( $wgUser->isAllowed( 'suppressrevision' ) ) { $suppress = " @@ -258,6 +261,8 @@ class FileDeleteForm { $suppress = ''; } + $wgOut->addModules( 'mediawiki.action.delete.file' ); + $checkWatch = $wgUser->getBoolOption( 'watchdeletion' ) || $wgUser->isWatched( $this->title ); $form = Xml::openElement( 'form', [ 'method' => 'post', 'action' => $this->getAction(), 'id' => 'mw-img-deleteconfirm' ] ) . @@ -286,8 +291,15 @@ class FileDeleteForm { Xml::label( wfMessage( 'filedelete-otherreason' )->text(), 'wpReason' ) . " " . - Xml::input( 'wpReason', 60, $wgRequest->getText( 'wpReason' ), - [ 'type' => 'text', 'maxlength' => '255', 'tabindex' => '2', 'id' => 'wpReason' ] ) . + Xml::input( 'wpReason', 60, $wgRequest->getText( 'wpReason' ), [ + 'type' => 'text', + // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP + // (e.g. emojis) count for two each. This limit is overridden in JS to instead count + // Unicode codepoints (or 255 UTF-8 bytes for old schema). + 'maxlength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT, + 'tabindex' => '2', + 'id' => 'wpReason' + ] ) . " {$suppress}"; diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 884c3f0f80..5b809e4994 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -3527,19 +3527,3 @@ function wfGetRusage() { return getrusage( 0 /* RUSAGE_SELF */ ); } } - -/** - * Begin profiling of a function - * @param string $functionname Name of the function we will profile - * @deprecated since 1.25 - */ -function wfProfileIn( $functionname ) { -} - -/** - * Stop profiling of a function - * @param string $functionname Name of the function we have profiled - * @deprecated since 1.25 - */ -function wfProfileOut( $functionname = 'missing' ) { -} diff --git a/includes/Linker.php b/includes/Linker.php index fb446b494a..5fc5eb1c29 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -1752,9 +1752,10 @@ class Linker { $dbr = wfGetDB( DB_REPLICA ); // Up to the value of $wgShowRollbackEditCount revisions are counted + $revQuery = Revision::getQueryInfo(); $res = $dbr->select( - 'revision', - [ 'rev_user_text', 'rev_deleted' ], + $revQuery['tables'], + [ 'rev_user_text' => $revQuery['fields']['rev_user_text'], 'rev_deleted' ], // $rev->getPage() returns null sometimes [ 'rev_page' => $rev->getTitle()->getArticleID() ], __METHOD__, @@ -1762,7 +1763,8 @@ class Linker { 'USE INDEX' => [ 'revision' => 'page_timestamp' ], 'ORDER BY' => 'rev_timestamp DESC', 'LIMIT' => $wgShowRollbackEditCount + 1 - ] + ], + $revQuery['joins'] ); $editCount = 0; diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 9077666ddf..8bb0a400d8 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -16,6 +16,7 @@ use MediaWiki\Preferences\PreferencesFactory; use MediaWiki\Shell\CommandFactory; use MediaWiki\Storage\BlobStore; use MediaWiki\Storage\BlobStoreFactory; +use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\RevisionFactory; use MediaWiki\Storage\RevisionLookup; use MediaWiki\Storage\RevisionStore; @@ -690,6 +691,30 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'ReadOnlyMode' ); } + /** + * @since 1.31 + * @return \UploadRevisionImporter + */ + public function getWikiRevisionUploadImporter() { + return $this->getService( 'UploadRevisionImporter' ); + } + + /** + * @since 1.31 + * @return \OldRevisionImporter + */ + public function getWikiRevisionOldRevisionImporter() { + return $this->getService( 'OldRevisionImporter' ); + } + + /** + * @since 1.31 + * @return \OldRevisionImporter + */ + public function getWikiRevisionOldRevisionImporterNoUpdates() { + return $this->getService( 'WikiRevisionOldRevisionImporterNoUpdates' ); + } + /** * @since 1.30 * @return CommandFactory @@ -746,6 +771,22 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'RevisionFactory' ); } + /** + * @since 1.31 + * @return NameTableStore + */ + public function getContentModelStore() { + return $this->getService( 'ContentModelStore' ); + } + + /** + * @since 1.31 + * @return NameTableStore + */ + public function getSlotRoleStore() { + return $this->getService( 'SlotRoleStore' ); + } + /** * @since 1.31 * @return PreferencesFactory @@ -770,6 +811,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'CommentStore' ); } + /** + * @since 1.31 + * @return ActorMigration + */ + public function getActorMigration() { + return $this->getService( 'ActorMigration' ); + } + /////////////////////////////////////////////////////////////////////////// // NOTE: When adding a service getter here, don't forget to add a test // case for it in MediaWikiServicesTest::provideGetters() and in diff --git a/includes/OutputPage.php b/includes/OutputPage.php index f95327a72b..4d6db4c35c 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -2945,14 +2945,14 @@ class OutputPage extends ContextSource { private function isUserJsPreview() { return $this->getConfig()->get( 'AllowUserJs' ) && $this->getTitle() - && $this->getTitle()->isJsSubpage() + && $this->getTitle()->isUserJsConfigPage() && $this->userCanPreview(); } protected function isUserCssPreview() { return $this->getConfig()->get( 'AllowUserCss' ) && $this->getTitle() - && $this->getTitle()->isCssSubpage() + && $this->getTitle()->isUserCssConfigPage() && $this->userCanPreview(); } @@ -3204,7 +3204,10 @@ class OutputPage extends ContextSource { } $title = $this->getTitle(); - if ( !$title->isJsSubpage() && !$title->isCssSubpage() ) { + if ( + !$title->isUserJsConfigPage() + && !$title->isUserCssConfigPage() + ) { return false; } if ( !$title->isSubpageOf( $user->getUserPage() ) ) { diff --git a/includes/ProtectionForm.php b/includes/ProtectionForm.php index 53608e849a..51c2923385 100644 --- a/includes/ProtectionForm.php +++ b/includes/ProtectionForm.php @@ -349,7 +349,9 @@ class ProtectionForm { $user = $context->getUser(); $output = $context->getOutput(); $lang = $context->getLanguage(); - $cascadingRestrictionLevels = $context->getConfig()->get( 'CascadingRestrictionLevels' ); + $conf = $context->getConfig(); + $cascadingRestrictionLevels = $conf->get( 'CascadingRestrictionLevels' ); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; $out = ''; if ( !$this->disabled ) { $output->addModules( 'mediawiki.legacy.protect' ); @@ -494,6 +496,13 @@ class ProtectionForm { $this->mReasonSelection, 'mwProtect-reason', 4 ); + // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP + // (e.g. emojis) count for two each. This limit is overridden in JS to instead count + // Unicode codepoints (or 180 UTF-8 bytes for old schema). + // Subtract arbitrary 75 to leave some space for the autogenerated null edit's summary + // and other texts chosen by dropdown menus on this page. + $maxlength = $oldCommentSchema ? 180 : CommentStore::COMMENT_CHARACTER_LIMIT - 75; + $out .= Xml::openElement( 'table', [ 'id' => 'mw-protect-table3' ] ) . Xml::openElement( 'tbody' ); $out .= " @@ -511,10 +520,7 @@ class ProtectionForm { " . Xml::input( 'mwProtect-reason', 60, $this->mReason, [ 'type' => 'text', - 'id' => 'mwProtect-reason', 'maxlength' => 180 ] ) . - // Limited maxlength as the database trims at 255 bytes and other texts - // chosen by dropdown menus on this page are also included in this database field. - // The byte limit of 180 bytes is enforced in javascript + 'id' => 'mwProtect-reason', 'maxlength' => $maxlength ] ) . " "; # Disallow watching is user is not logged in diff --git a/includes/Revision.php b/includes/Revision.php index d9d3149463..22eb1150e3 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -29,7 +29,6 @@ use MediaWiki\Storage\RevisionStore; use MediaWiki\Storage\RevisionStoreRecord; use MediaWiki\Storage\SlotRecord; use MediaWiki\Storage\SqlBlobStore; -use MediaWiki\User\UserIdentityValue; use Wikimedia\Rdbms\IDatabase; use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; @@ -316,7 +315,18 @@ class Revision implements IDBAccessObject { * @return array */ public static function userJoinCond() { + global $wgActorTableSchemaMigrationStage; + wfDeprecated( __METHOD__, '1.31' ); + if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) { + // If code is using this instead of self::getQueryInfo(), there's + // no way the join it's trying to do can work once the old fields + // aren't being written anymore. + throw new BadMethodCallException( + 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH' + ); + } + return [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ]; } @@ -339,7 +349,17 @@ class Revision implements IDBAccessObject { * @return array */ public static function selectFields() { - global $wgContentHandlerUseDB; + global $wgContentHandlerUseDB, $wgActorTableSchemaMigrationStage; + + if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) { + // If code is using this instead of self::getQueryInfo(), there's a + // decent chance it's going to try to directly access + // $row->rev_user or $row->rev_user_text and we can't give it + // useful values here once those aren't being written anymore. + throw new BadMethodCallException( + 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH' + ); + } wfDeprecated( __METHOD__, '1.31' ); @@ -350,6 +370,7 @@ class Revision implements IDBAccessObject { 'rev_timestamp', 'rev_user_text', 'rev_user', + 'rev_actor' => 'NULL', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -374,7 +395,17 @@ class Revision implements IDBAccessObject { * @return array */ public static function selectArchiveFields() { - global $wgContentHandlerUseDB; + global $wgContentHandlerUseDB, $wgActorTableSchemaMigrationStage; + + if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) { + // If code is using this instead of self::getQueryInfo(), there's a + // decent chance it's going to try to directly access + // $row->ar_user or $row->ar_user_text and we can't give it + // useful values here once those aren't being written anymore. + throw new BadMethodCallException( + 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH' + ); + } wfDeprecated( __METHOD__, '1.31' ); @@ -387,6 +418,7 @@ class Revision implements IDBAccessObject { 'ar_timestamp', 'ar_user_text', 'ar_user', + 'ar_actor' => 'NULL', 'ar_minor_edit', 'ar_deleted', 'ar_len', @@ -623,7 +655,7 @@ class Revision implements IDBAccessObject { */ public function setUserIdAndName( $id, $name ) { if ( $this->mRecord instanceof MutableRevisionRecord ) { - $user = new UserIdentityValue( intval( $id ), $name ); + $user = User::newFromAnyId( intval( $id ), $name, null ); $this->mRecord->setUser( $user ); } else { throw new MWException( __METHOD__ . ' is not supported on this instance' ); @@ -906,11 +938,6 @@ class Revision implements IDBAccessObject { return $this->mRecord->getContent( 'main', $audience, $user ); } catch ( RevisionAccessException $e ) { - wfDebugLog( - 'T184670', - __METHOD__ . ": Cannot get content: " . $e->getMessage() . - "\n" . $e->getTraceAsString() - ); return null; } } diff --git a/includes/RevisionList.php b/includes/RevisionList.php index fa454e07e3..5243cc65dd 100644 --- a/includes/RevisionList.php +++ b/includes/RevisionList.php @@ -203,6 +203,16 @@ abstract class RevisionItemBase { return false; } + /** + * Get the DB field name storing actor ids. + * Override this function. + * @since 1.31 + * @return bool + */ + public function getAuthorActorField() { + return false; + } + /** * Get the ID, as it would appear in the ids URL parameter * @return int @@ -257,6 +267,16 @@ abstract class RevisionItemBase { return strval( $this->row->$field ); } + /** + * Get the author actor ID + * @since 1.31 + * @return string + */ + public function getAuthorActor() { + $field = $this->getAuthorActorField(); + return strval( $this->row->$field ); + } + /** * Returns true if the current user can view the item */ diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 8b0452db3d..08d343b1b2 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -45,6 +45,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Preferences\DefaultPreferencesFactory; use MediaWiki\Shell\CommandFactory; use MediaWiki\Storage\BlobStoreFactory; +use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\RevisionStore; use MediaWiki\Storage\SqlBlobStore; use Wikimedia\ObjectFactory; @@ -179,7 +180,8 @@ return [ 'WatchedItemQueryService' => function ( MediaWikiServices $services ) { return new WatchedItemQueryService( $services->getDBLoadBalancer(), - $services->getCommentStore() + $services->getCommentStore(), + $services->getActorMigration() ); }, @@ -442,6 +444,29 @@ return [ ); }, + 'UploadRevisionImporter' => function ( MediaWikiServices $services ) { + return new ImportableUploadRevisionImporter( + $services->getMainConfig()->get( 'EnableUploads' ), + LoggerFactory::getInstance( 'UploadRevisionImporter' ) + ); + }, + + 'OldRevisionImporter' => function ( MediaWikiServices $services ) { + return new ImportableOldRevisionImporter( + true, + LoggerFactory::getInstance( 'OldRevisionImporter' ), + $services->getDBLoadBalancer() + ); + }, + + 'WikiRevisionOldRevisionImporterNoUpdates' => function ( MediaWikiServices $services ) { + return new ImportableOldRevisionImporter( + false, + LoggerFactory::getInstance( 'OldRevisionImporter' ), + $services->getDBLoadBalancer() + ); + }, + 'ShellCommandFactory' => function ( MediaWikiServices $services ) { $config = $services->getMainConfig(); @@ -477,7 +502,8 @@ return [ $services->getDBLoadBalancer(), $blobStore, $services->getMainWANObjectCache(), - $services->getCommentStore() + $services->getCommentStore(), + $services->getActorMigration() ); $store->setLogger( LoggerFactory::getInstance( 'RevisionStore' ) ); @@ -514,6 +540,35 @@ return [ return $services->getBlobStoreFactory()->newSqlBlobStore(); }, + 'ContentModelStore' => function ( MediaWikiServices $services ) { + return new NameTableStore( + $services->getDBLoadBalancer(), + $services->getMainWANObjectCache(), + LoggerFactory::getInstance( 'NameTableSqlStore' ), + 'content_models', + 'model_id', + 'model_name' + /** + * No strtolower normalization is added to the service as there are examples of + * extensions that do not stick to this assumption. + * - extensions/examples/DataPages define( 'CONTENT_MODEL_XML_DATA','XML_DATA' ); + * - extensions/Scribunto define( 'CONTENT_MODEL_SCRIBUNTO', 'Scribunto' ); + */ + ); + }, + + 'SlotRoleStore' => function ( MediaWikiServices $services ) { + return new NameTableStore( + $services->getDBLoadBalancer(), + $services->getMainWANObjectCache(), + LoggerFactory::getInstance( 'NameTableSqlStore' ), + 'slot_roles', + 'role_id', + 'role_name', + 'strtolower' + ); + }, + 'PreferencesFactory' => function ( MediaWikiServices $services ) { global $wgContLang; $authManager = AuthManager::singleton(); @@ -532,7 +587,13 @@ return [ $wgContLang, $services->getMainConfig()->get( 'CommentTableSchemaMigrationStage' ) ); - } + }, + + 'ActorMigration' => function ( MediaWikiServices $services ) { + return new ActorMigration( + $services->getMainConfig()->get( 'ActorTableSchemaMigrationStage' ) + ); + }, /////////////////////////////////////////////////////////////////////////// // NOTE: When adding a service here, don't forget to add a getter function diff --git a/includes/SiteStatsInit.php b/includes/SiteStatsInit.php index f527cb226b..8adb2181ea 100644 --- a/includes/SiteStatsInit.php +++ b/includes/SiteStatsInit.php @@ -79,13 +79,6 @@ class SiteStatsInit { if ( $config->get( 'ArticleCountMethod' ) == 'link' ) { $tables[] = 'pagelinks'; $conds[] = 'pl_from=page_id'; - } elseif ( $config->get( 'ArticleCountMethod' ) == 'comma' ) { - // To make a correct check for this, we would need, for each page, - // to load the text, maybe uncompress it, maybe decode it and then - // check if there's one comma. - // But one thing we are sure is that if the page is empty, it can't - // contain a comma :) - $conds[] = 'page_len > 0'; } $this->articles = $this->dbr->selectField( diff --git a/includes/Storage/BlobStore.php b/includes/Storage/BlobStore.php index 28caf3a412..8b1112b277 100644 --- a/includes/Storage/BlobStore.php +++ b/includes/Storage/BlobStore.php @@ -110,4 +110,10 @@ interface BlobStore { */ public function storeBlob( $data, $hints = [] ); + /** + * Check if the blob metadata or backing blob data store is read-only + * + * @return bool + */ + public function isReadOnly(); } diff --git a/includes/Storage/NameTableAccessException.php b/includes/Storage/NameTableAccessException.php new file mode 100644 index 0000000000..393cb1fa7e --- /dev/null +++ b/includes/Storage/NameTableAccessException.php @@ -0,0 +1,45 @@ +loadBalancer = $dbLoadBalancer; + $this->cache = $cache; + $this->logger = $logger; + $this->table = $table; + $this->idField = $idField; + $this->nameField = $nameField; + $this->normalizationCallback = $normalizationCallback; + $this->wikiId = $wikiId; + $this->cacheTTL = IExpiringStore::TTL_MONTH; + } + + /** + * @param int $index A database index, like DB_MASTER or DB_REPLICA + * @param int $flags Database connection flags + * + * @return IDatabase + */ + private function getDBConnection( $index, $flags = 0 ) { + return $this->loadBalancer->getConnection( $index, [], $this->wikiId, $flags ); + } + + private function getCacheKey() { + return $this->cache->makeKey( 'NameTableSqlStore', $this->table, $this->wikiId ); + } + + /** + * @param string $name + * @return string + */ + private function normalizeName( $name ) { + if ( $this->normalizationCallback === null ) { + return $name; + } + return call_user_func( $this->normalizationCallback, $name ); + } + + /** + * Acquire the id of the given name. + * This creates a row in the table if it doesn't already exist. + * + * @param string $name + * @throws NameTableAccessException + * @return int + */ + public function acquireId( $name ) { + Assert::parameterType( 'string', $name, '$name' ); + $name = $this->normalizeName( $name ); + + $table = $this->getTableFromCachesOrReplica(); + $searchResult = array_search( $name, $table, true ); + if ( $searchResult === false ) { + $id = $this->store( $name ); + if ( $id === null ) { + // RACE: $name was already in the db, probably just inserted, so load from master + // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs + $table = $this->loadTable( + $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTO ) + ); + $searchResult = array_search( $name, $table, true ); + if ( $searchResult === false ) { + // Insert failed due to IGNORE flag, but DB_MASTER didn't give us the data + $m = "No insert possible but master didn't give us a record for " . + "'{$name}' in '{$this->table}'"; + $this->logger->error( $m ); + throw new NameTableAccessException( $m ); + } + $this->purgeWANCache( + function () { + $this->cache->reap( $this->getCacheKey(), INF ); + } + ); + } else { + $table[$id] = $name; + $searchResult = $id; + // As store returned an ID we know we inserted so delete from WAN cache + $this->purgeWANCache( + function () { + $this->cache->delete( $this->getCacheKey() ); + } + ); + } + $this->tableCache = $table; + } + + return $searchResult; + } + + /** + * Get the id of the given name. + * If the name doesn't exist this will throw. + * This should be used in cases where we believe the name already exists or want to check for + * existence. + * + * @param string $name + * @throws NameTableAccessException The name does not exist + * @return int Id + */ + public function getId( $name ) { + Assert::parameterType( 'string', $name, '$name' ); + $name = $this->normalizeName( $name ); + + $table = $this->getTableFromCachesOrReplica(); + $searchResult = array_search( $name, $table, true ); + + if ( $searchResult !== false ) { + return $searchResult; + } + + throw NameTableAccessException::newFromDetails( $this->table, 'name', $name ); + } + + /** + * Get the name of the given id. + * If the id doesn't exist this will throw. + * This should be used in cases where we believe the id already exists. + * + * Note: Calls to this method will result in a master select for non existing IDs. + * + * @param int $id + * @throws NameTableAccessException The id does not exist + * @return string name + */ + public function getName( $id ) { + Assert::parameterType( 'integer', $id, '$id' ); + + $table = $this->getTableFromCachesOrReplica(); + if ( array_key_exists( $id, $table ) ) { + return $table[$id]; + } + + $table = $this->cache->getWithSetCallback( + $this->getCacheKey(), + $this->cacheTTL, + function ( $oldValue, &$ttl, &$setOpts ) use ( $id ) { + // Check if cached value is up-to-date enough to have $id + if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) { + // Completely leave the cache key alone + $ttl = WANObjectCache::TTL_UNCACHEABLE; + // Use the old value + return $oldValue; + } + // Regenerate from replica DB, and master DB if needed + foreach ( [ DB_REPLICA, DB_MASTER ] as $source ) { + // Log a fallback to master + if ( $source === DB_MASTER ) { + $this->logger->info( + __METHOD__ . 'falling back to master select from ' . + $this->table . ' with id ' . $id + ); + } + $db = $this->getDBConnection( $source ); + $cacheSetOpts = Database::getCacheSetOptions( $db ); + $table = $this->loadTable( $db ); + if ( array_key_exists( $id, $table ) ) { + break; // found it + } + } + // Use the value from last source checked + $setOpts += $cacheSetOpts; + + return $table; + }, + [ 'minAsOf' => INF ] // force callback run + ); + + $this->tableCache = $table; + + if ( array_key_exists( $id, $table ) ) { + return $table[$id]; + } + + throw NameTableAccessException::newFromDetails( $this->table, 'id', $id ); + } + + /** + * Get the whole table, in no particular order as a map of ids to names. + * This method could be subject to DB or cache lag. + * + * @return string[] keys are the name ids, values are the names themselves + * Example: [ 1 => 'foo', 3 => 'bar' ] + */ + public function getMap() { + return $this->getTableFromCachesOrReplica(); + } + + /** + * @return string[] + */ + private function getTableFromCachesOrReplica() { + if ( $this->tableCache !== null ) { + return $this->tableCache; + } + + $table = $this->cache->getWithSetCallback( + $this->getCacheKey(), + $this->cacheTTL, + function ( $oldValue, &$ttl, &$setOpts ) { + $dbr = $this->getDBConnection( DB_REPLICA ); + $setOpts += Database::getCacheSetOptions( $dbr ); + return $this->loadTable( $dbr ); + } + ); + + $this->tableCache = $table; + + return $table; + } + + /** + * Reap the WANCache entry for this table. + * + * @param callable $purgeCallback callback to 'purge' the WAN cache + */ + private function purgeWANCache( $purgeCallback ) { + // If the LB has no DB changes don't both with onTransactionPreCommitOrIdle + if ( !$this->loadBalancer->hasOrMadeRecentMasterChanges() ) { + $purgeCallback(); + return; + } + + $this->getDBConnection( DB_MASTER ) + ->onTransactionPreCommitOrIdle( $purgeCallback, __METHOD__ ); + } + + /** + * Gets the table from the db + * + * @param IDatabase $db + * + * @return string[] + */ + private function loadTable( IDatabase $db ) { + $result = $db->select( + $this->table, + [ + 'id' => $this->idField, + 'name' => $this->nameField + ], + [], + __METHOD__ + ); + + $assocArray = []; + foreach ( $result as $row ) { + $assocArray[$row->id] = $row->name; + } + + return $assocArray; + } + + /** + * Stores the given name in the DB, returning the ID when an insert occurs. + * + * @param string $name + * @return int|null int if we know the ID, null if we don't + */ + private function store( $name ) { + Assert::parameterType( 'string', $name, '$name' ); + Assert::parameter( $name !== '', '$name', 'should not be an empty string' ); + // Note: this is only called internally so normalization of $name has already occurred. + + $dbw = $this->getDBConnection( DB_MASTER ); + + $dbw->insert( + $this->table, + [ $this->nameField => $name ], + __METHOD__, + [ 'IGNORE' ] + ); + + if ( $dbw->affectedRows() === 0 ) { + $this->logger->info( + 'Tried to insert name into table ' . $this->table . ', but value already existed.' + ); + return null; + } + + return $dbw->insertId(); + } + +} diff --git a/includes/Storage/RevisionRecord.php b/includes/Storage/RevisionRecord.php index 8734f48848..6d83e1c100 100644 --- a/includes/Storage/RevisionRecord.php +++ b/includes/Storage/RevisionRecord.php @@ -196,6 +196,17 @@ abstract class RevisionRecord { return $slot; } + /** + * Returns whether the given slot is defined in this revision. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function hasSlot( $role ) { + return $this->mSlots->hasSlot( $role ); + } + /** * Returns the slot names (roles) of all slots present in this revision. * getContent() will succeed only for the names returned by this method. diff --git a/includes/Storage/RevisionSlots.php b/includes/Storage/RevisionSlots.php index 8d3d7e3d70..7fa5431d38 100644 --- a/includes/Storage/RevisionSlots.php +++ b/includes/Storage/RevisionSlots.php @@ -110,6 +110,19 @@ class RevisionSlots { } } + /** + * Returns whether the given slot is set. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function hasSlot( $role ) { + $slots = $this->getSlots(); + + return isset( $slots[$role] ); + } + /** * Returns the slot names (roles) of all slots present in this revision. * getContent() will succeed only for the names returned by this method. diff --git a/includes/Storage/RevisionStore.php b/includes/Storage/RevisionStore.php index e7c9060411..e00deef6cc 100644 --- a/includes/Storage/RevisionStore.php +++ b/includes/Storage/RevisionStore.php @@ -26,6 +26,7 @@ namespace MediaWiki\Storage; +use ActorMigration; use CommentStore; use CommentStoreComment; use Content; @@ -97,6 +98,11 @@ class RevisionStore */ private $commentStore; + /** + * @var ActorMigration + */ + private $actorMigration; + /** * @var LoggerInterface */ @@ -109,6 +115,7 @@ class RevisionStore * @param SqlBlobStore $blobStore * @param WANObjectCache $cache * @param CommentStore $commentStore + * @param ActorMigration $actorMigration * @param bool|string $wikiId */ public function __construct( @@ -116,6 +123,7 @@ class RevisionStore SqlBlobStore $blobStore, WANObjectCache $cache, CommentStore $commentStore, + ActorMigration $actorMigration, $wikiId = false ) { Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' ); @@ -124,6 +132,7 @@ class RevisionStore $this->blobStore = $blobStore; $this->cache = $cache; $this->commentStore = $commentStore; + $this->actorMigration = $actorMigration; $this->wikiId = $wikiId; $this->logger = new NullLogger(); } @@ -132,6 +141,13 @@ class RevisionStore $this->logger = $logger; } + /** + * @return bool Whether the store is read-only + */ + public function isReadOnly() { + return $this->blobStore->isReadOnly(); + } + /** * @return bool */ @@ -381,14 +397,16 @@ class RevisionStore $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); $timestamp = $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); + // Checks. + $this->failOnNull( $user->getId(), 'user field' ); + $this->failOnEmpty( $user->getName(), 'user_text field' ); + # Record the edit in revisions $row = [ 'rev_page' => $pageId, 'rev_parent_id' => $parentId, 'rev_text_id' => $textId, 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, - 'rev_user' => $this->failOnNull( $user->getId(), 'user field' ), - 'rev_user_text' => $this->failOnEmpty( $user->getName(), 'user_text field' ), 'rev_timestamp' => $dbw->timestamp( $timestamp ), 'rev_deleted' => $rev->getVisibility(), 'rev_len' => $size, @@ -404,6 +422,10 @@ class RevisionStore $this->commentStore->insertWithTempTable( $dbw, 'rev_comment', $comment ); $row += $commentFields; + list( $actorFields, $actorCallback ) = + $this->actorMigration->getInsertValuesWithTempTable( $dbw, 'rev_user', $user ); + $row += $actorFields; + if ( $this->contentHandlerUseDB ) { // MCR migration note: rev_content_model and rev_content_format will go away @@ -421,13 +443,14 @@ class RevisionStore $row['rev_id'] = intval( $dbw->insertId() ); } $commentCallback( $row['rev_id'] ); + $actorCallback( $row['rev_id'], $row ); // Insert IP revision into ip_changes for use when querying for a range. - if ( $row['rev_user'] === 0 && IP::isValid( $row['rev_user_text'] ) ) { + if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) { $ipcRow = [ 'ipc_rev_id' => $row['rev_id'], 'ipc_rev_timestamp' => $row['rev_timestamp'], - 'ipc_hex' => IP::toHex( $row['rev_user_text'] ), + 'ipc_hex' => IP::toHex( $user->getName() ), ]; $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); } @@ -435,8 +458,6 @@ class RevisionStore $newSlot = SlotRecord::newSaved( $row['rev_id'], $blobAddress, $slot ); $slots = new RevisionSlots( [ 'main' => $newSlot ] ); - $user = new UserIdentityValue( intval( $row['rev_user'] ), $row['rev_user_text'] ); - $rev = new RevisionStoreRecord( $title, $user, @@ -576,6 +597,7 @@ class RevisionStore 'page' => $title->getArticleID(), 'user_text' => $user->getName(), 'user' => $user->getId(), + 'actor' => $user->getActorId(), 'comment' => $comment, 'minor_edit' => $minor, 'text_id' => $current->rev_text_id, @@ -591,7 +613,7 @@ class RevisionStore $fields['title'] = Title::makeTitle( $current->page_namespace, $current->page_title ); - $mainSlot = $this->emulateMainSlot_1_29( $fields, 0, $title ); + $mainSlot = $this->emulateMainSlot_1_29( $fields, self::READ_LATEST, $title ); $revision = new MutableRevisionRecord( $title, $this->wikiId ); $this->initializeMutableRevisionFromArray( $revision, $fields ); $revision->setSlot( $mainSlot ); @@ -647,9 +669,10 @@ class RevisionStore } // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that! + $actorWhere = $this->actorMigration->getWhere( $dbr, 'rc_user', $rev->getUser(), false ); $rc = RecentChange::newFromConds( [ - 'rc_user_text' => $userIdentity->getName(), + $actorWhere['conds'], 'rc_timestamp' => $dbr->timestamp( $rev->getTimestamp() ), 'rc_this_oldid' => $rev->getId() ], @@ -684,6 +707,7 @@ class RevisionStore 'ar_timestamp' => 'rev_timestamp', 'ar_user_text' => 'rev_user_text', 'ar_user' => 'rev_user', + 'ar_actor' => 'rev_actor', 'ar_minor_edit' => 'rev_minor_edit', 'ar_deleted' => 'rev_deleted', 'ar_len' => 'rev_len', @@ -734,14 +758,12 @@ class RevisionStore if ( is_object( $row ) ) { // archive row - if ( !isset( $row->rev_id ) && isset( $row->ar_user ) ) { + if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) { $row = $this->mapArchiveFields( $row ); } if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) { $mainSlotRow->cont_address = 'tt:' . $row->rev_text_id; - } elseif ( isset( $row->ar_id ) ) { - $mainSlotRow->cont_address = 'ar:' . $row->ar_id; } if ( isset( $row->old_text ) ) { @@ -1075,7 +1097,16 @@ class RevisionStore $row->$field = $value; } - $user = $this->getUserIdentityFromRowObject( $row, 'ar_' ); + try { + $user = User::newFromAnyId( + isset( $row->ar_user ) ? $row->ar_user : null, + isset( $row->ar_user_text ) ? $row->ar_user_text : null, + isset( $row->ar_actor ) ? $row->ar_actor : null + ); + } catch ( InvalidArgumentException $ex ) { + wfWarn( __METHOD__ . ': ' . $ex->getMessage() ); + $user = new UserIdentityValue( 0, '', 0 ); + } $comment = $this->commentStore // Legacy because $row may have come from self::selectFields() @@ -1087,34 +1118,6 @@ class RevisionStore return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); } - /** - * @param object $row - * @param string $prefix Field prefix, such as 'rev_' or 'ar_'. - * - * @return UserIdentityValue - */ - private function getUserIdentityFromRowObject( $row, $prefix = 'rev_' ) { - $idField = "{$prefix}user"; - $nameField = "{$prefix}user_text"; - - $userId = intval( $row->$idField ); - - if ( isset( $row->user_name ) ) { - $userName = $row->user_name; - } elseif ( isset( $row->$nameField ) ) { - $userName = $row->$nameField; - } else { - $userName = User::whoIs( $userId ); - } - - if ( $userName === false ) { - wfWarn( __METHOD__ . ': Cannot determine user name for user ID ' . $userId ); - $userName = ''; - } - - return new UserIdentityValue( $userId, $userName ); - } - /** * @see RevisionFactory::newRevisionFromRow_1_29 * @@ -1145,7 +1148,16 @@ class RevisionStore } } - $user = $this->getUserIdentityFromRowObject( $row ); + try { + $user = User::newFromAnyId( + isset( $row->rev_user ) ? $row->rev_user : null, + isset( $row->rev_user_text ) ? $row->rev_user_text : null, + isset( $row->rev_actor ) ? $row->rev_actor : null + ); + } catch ( InvalidArgumentException $ex ) { + wfWarn( __METHOD__ . ': ' . $ex->getMessage() ); + $user = new UserIdentityValue( 0, '', 0 ); + } $comment = $this->commentStore // Legacy because $row may have come from self::selectFields() @@ -1224,27 +1236,6 @@ class RevisionStore } } - // Replaces old lazy loading logic in Revision::getUserText. - if ( !isset( $fields['user_text'] ) && isset( $fields['user'] ) ) { - if ( $fields['user'] instanceof UserIdentity ) { - /** @var User $user */ - $user = $fields['user']; - $fields['user_text'] = $user->getName(); - $fields['user'] = $user->getId(); - } else { - // TODO: wrap this in a callback to make it lazy again. - $name = $fields['user'] === 0 ? false : User::whoIs( $fields['user'] ); - - if ( $name === false ) { - throw new MWException( - 'user_text not given, and unknown user ID ' . $fields['user'] - ); - } - - $fields['user_text'] = $name; - } - } - if ( isset( $fields['comment'] ) && !( $fields['comment'] instanceof CommentStoreComment ) @@ -1287,16 +1278,15 @@ class RevisionStore if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) { $user = $fields['user']; - } elseif ( isset( $fields['user'] ) && isset( $fields['user_text'] ) ) { - $user = new UserIdentityValue( intval( $fields['user'] ), $fields['user_text'] ); - } elseif ( isset( $fields['user'] ) ) { - $user = User::newFromId( intval( $fields['user'] ) ); - } elseif ( isset( $fields['user_text'] ) ) { - $user = User::newFromName( $fields['user_text'] ); - - // User::newFromName will return false for IP addresses (and invalid names) - if ( $user == false ) { - $user = new UserIdentityValue( 0, $fields['user_text'] ); + } else { + try { + $user = User::newFromAnyId( + isset( $fields['user'] ) ? $fields['user'] : null, + isset( $fields['user_text'] ) ? $fields['user_text'] : null, + isset( $fields['actor'] ) ? $fields['actor'] : null + ); + } catch ( InvalidArgumentException $ex ) { + $user = null; } } @@ -1613,8 +1603,6 @@ class RevisionStore 'rev_page', 'rev_text_id', 'rev_timestamp', - 'rev_user_text', - 'rev_user', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -1627,6 +1615,11 @@ class RevisionStore $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); + $actorQuery = $this->actorMigration->getJoin( 'rev_user' ); + $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] ); + $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] ); + $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] ); + if ( $this->contentHandlerUseDB ) { $ret['fields'][] = 'rev_content_format'; $ret['fields'][] = 'rev_content_model'; @@ -1650,7 +1643,8 @@ class RevisionStore $ret['fields'] = array_merge( $ret['fields'], [ 'user_name', ] ); - $ret['joins']['user'] = [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ]; + $u = $actorQuery['fields']['rev_user']; + $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ]; } if ( in_array( 'text', $options, true ) ) { @@ -1680,8 +1674,9 @@ class RevisionStore */ public function getArchiveQueryInfo() { $commentQuery = $this->commentStore->getJoin( 'ar_comment' ); + $actorQuery = $this->actorMigration->getJoin( 'ar_user' ); $ret = [ - 'tables' => [ 'archive' ] + $commentQuery['tables'], + 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'], 'fields' => [ 'ar_id', 'ar_page_id', @@ -1691,15 +1686,13 @@ class RevisionStore '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'], + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'joins' => $commentQuery['joins'] + $actorQuery['joins'], ]; if ( $this->contentHandlerUseDB ) { @@ -1922,15 +1915,19 @@ class RevisionStore return false; } + $revQuery = self::getQueryInfo(); $res = $db->select( - 'revision', - 'rev_user', + $revQuery['tables'], + [ + 'rev_user' => $revQuery['fields']['rev_user'], + ], [ 'rev_page' => $pageId, 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) ], __METHOD__, - [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] + [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ], + $revQuery['joins'] ); foreach ( $res as $row ) { if ( $row->rev_user != $userId ) { diff --git a/includes/Storage/SqlBlobStore.php b/includes/Storage/SqlBlobStore.php index 031cb587ed..0ff7c13343 100644 --- a/includes/Storage/SqlBlobStore.php +++ b/includes/Storage/SqlBlobStore.php @@ -299,7 +299,6 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { list( $schema, $id, ) = self::splitBlobAddress( $blobAddress ); //TODO: MCR: also support 'ex' schema with ExternalStore URLs, plus flags encoded in the URL! - //TODO: MCR: also support 'ar' schema for content blobs in old style archive rows! if ( $schema === 'tt' ) { $textId = intval( $id ); } else { @@ -591,4 +590,11 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { return [ $schema, $id, $parameters ]; } + public function isReadOnly() { + if ( $this->useExternalStore && ExternalStore::defaultStoresAreReadOnly() ) { + return true; + } + + return ( $this->getDBLoadBalancer()->getReadOnlyReason() !== false ); + } } diff --git a/includes/Title.php b/includes/Title.php index 1be986376f..66aadebc19 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -1303,22 +1303,52 @@ class Title implements LinkTarget { * show "inactive" CSS or JS. * * @return bool - * @todo FIXME: Rename to isSiteConfigPage() and remove deprecated hook + * @since 1.31 + */ + public function isSiteConfigPage() { + return ( + NS_MEDIAWIKI == $this->mNamespace + && ( + $this->hasContentModel( CONTENT_MODEL_CSS ) + || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) + ) + ); + } + + /** + * @return bool + * @deprecated Since 1.31; use ::isSiteConfigPage() instead */ public function isCssOrJsPage() { - $isCssOrJsPage = NS_MEDIAWIKI == $this->mNamespace - && ( $this->hasContentModel( CONTENT_MODEL_CSS ) - || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); + wfDeprecated( __METHOD__, '1.31' ); + return ( NS_MEDIAWIKI == $this->mNamespace + && ( $this->hasContentModel( CONTENT_MODEL_CSS ) + || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) ); + } - return $isCssOrJsPage; + /** + * Is this a "config" (.css or .js) sub-page of a user page? + * + * @return bool + * @since 1.31 + */ + public function isUserConfigPage() { + return ( + NS_USER == $this->mNamespace + && $this->isSubpage() + && ( + $this->hasContentModel( CONTENT_MODEL_CSS ) + || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) + ) + ); } /** - * Is this a .css or .js subpage of a user page? * @return bool - * @todo FIXME: Rename to isUserConfigPage() + * @deprecated Since 1.31; use ::isUserConfigPage() instead */ public function isCssJsSubpage() { + wfDeprecated( __METHOD__, '1.31' ); return ( NS_USER == $this->mNamespace && $this->isSubpage() && ( $this->hasContentModel( CONTENT_MODEL_CSS ) || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) ); @@ -1328,8 +1358,9 @@ class Title implements LinkTarget { * Trim down a .css or .js subpage title to get the corresponding skin name * * @return string Containing skin name from .css or .js subpage title + * @since 1.31 */ - public function getSkinFromCssJsSubpage() { + public function getSkinFromConfigSubpage() { $subpage = explode( '/', $this->mTextform ); $subpage = $subpage[count( $subpage ) - 1]; $lastdot = strrpos( $subpage, '.' ); @@ -1340,23 +1371,58 @@ class Title implements LinkTarget { } /** - * Is this a .css subpage of a user page? + * @deprecated Since 1.31; use ::getSkinFromConfigSubpage() instead + * @return string Containing skin name from .css or .js subpage title + */ + public function getSkinFromCssJsSubpage() { + wfDeprecated( __METHOD__, '1.31' ); + return $this->getSkinFromConfigSubpage(); + } + + /** + * Is this a CSS "config" sub-page of a user page? * * @return bool + * @since 1.31 + */ + public function isUserCssConfigPage() { + return ( + NS_USER == $this->mNamespace + && $this->isSubpage() + && $this->hasContentModel( CONTENT_MODEL_CSS ) + ); + } + + /** + * @deprecated Since 1.31; use ::isUserCssConfigPage() + * @return bool */ public function isCssSubpage() { - return ( NS_USER == $this->mNamespace && $this->isSubpage() - && $this->hasContentModel( CONTENT_MODEL_CSS ) ); + wfDeprecated( __METHOD__, '1.31' ); + return $this->isUserCssConfigPage(); } /** * Is this a .js subpage of a user page? * * @return bool + * @since 1.31 + */ + public function isUserJsConfigPage() { + return ( + NS_USER == $this->mNamespace + && $this->isSubpage() + && $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) + ); + } + + /** + * @deprecated Since 1.31; use ::isUserJsConfigPage() + * @return bool */ public function isJsSubpage() { - return ( NS_USER == $this->mNamespace && $this->isSubpage() - && $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); + wfDeprecated( __METHOD__, '1.31' ); + return $this->isUserJsConfigPage(); } /** @@ -2260,20 +2326,33 @@ class Title implements LinkTarget { * * @return array List of errors */ - private function checkCSSandJSPermissions( $action, $user, $errors, $rigor, $short ) { + private function checkUserConfigPermissions( $action, $user, $errors, $rigor, $short ) { # Protect css/js subpages of user pages # XXX: this might be better using restrictions + if ( $action != 'patrol' ) { if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $this->mTextform ) ) { - if ( $this->isCssSubpage() && !$user->isAllowedAny( 'editmyusercss', 'editusercss' ) ) { + if ( + $this->isUserCssConfigPage() + && !$user->isAllowedAny( 'editmyusercss', 'editusercss' ) + ) { $errors[] = [ 'mycustomcssprotected', $action ]; - } elseif ( $this->isJsSubpage() && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' ) ) { + } elseif ( + $this->isUserJsConfigPage() + && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' ) + ) { $errors[] = [ 'mycustomjsprotected', $action ]; } } else { - if ( $this->isCssSubpage() && !$user->isAllowed( 'editusercss' ) ) { + if ( + $this->isUserCssConfigPage() + && !$user->isAllowed( 'editusercss' ) + ) { $errors[] = [ 'customcssprotected', $action ]; - } elseif ( $this->isJsSubpage() && !$user->isAllowed( 'edituserjs' ) ) { + } elseif ( + $this->isUserJsConfigPage() + && !$user->isAllowed( 'edituserjs' ) + ) { $errors[] = [ 'customjsprotected', $action ]; } } @@ -2330,7 +2409,7 @@ class Title implements LinkTarget { * @return array List of errors */ private function checkCascadingSourcesRestrictions( $action, $user, $errors, $rigor, $short ) { - if ( $rigor !== 'quick' && !$this->isCssJsSubpage() ) { + if ( $rigor !== 'quick' && !$this->isUserConfigPage() ) { # We /could/ use the protection level on the source page, but it's # fairly ugly as we have to establish a precedence hierarchy for pages # included by multiple cascade-protected pages. So just restrict @@ -2611,7 +2690,7 @@ class Title implements LinkTarget { 'checkReadPermissions', 'checkUserBlock', // for wgBlockDisablesLogin ]; - # Don't call checkSpecialsAndNSPermissions or checkCSSandJSPermissions + # Don't call checkSpecialsAndNSPermissions or checkUserConfigPermissions # here as it will lead to duplicate error messages. This is okay to do # since anywhere that checks for create will also check for edit, and # those checks are called for edit. @@ -2629,7 +2708,7 @@ class Title implements LinkTarget { 'checkQuickPermissions', 'checkPermissionHooks', 'checkSpecialsAndNSPermissions', - 'checkCSSandJSPermissions', + 'checkUserConfigPermissions', 'checkPageRestrictions', 'checkCascadingSourcesRestrictions', 'checkActionPermissions', @@ -3743,9 +3822,9 @@ class Title implements LinkTarget { } // If we are looking at a css/js user subpage, purge the action=raw. - if ( $this->isJsSubpage() ) { + if ( $this->isUserJsConfigPage() ) { $urls[] = $this->getInternalURL( 'action=raw&ctype=text/javascript' ); - } elseif ( $this->isCssSubpage() ) { + } elseif ( $this->isUserCssConfigPage() ) { $urls[] = $this->getInternalURL( 'action=raw&ctype=text/css' ); } @@ -4389,17 +4468,18 @@ class Title implements LinkTarget { return $authors; } $dbr = wfGetDB( DB_REPLICA ); - $res = $dbr->select( 'revision', 'DISTINCT rev_user_text', + $revQuery = Revision::getQueryInfo(); + $authors = $dbr->selectFieldValues( + $revQuery['tables'], + $revQuery['fields']['rev_user_text'], [ 'rev_page' => $this->getArticleID(), "rev_timestamp $old_cmp " . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ), "rev_timestamp $new_cmp " . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) ) ], __METHOD__, - [ 'LIMIT' => $limit + 1 ] // add one so caller knows it was truncated + [ 'DISTINCT', 'LIMIT' => $limit + 1 ], // add one so caller knows it was truncated + $revQuery['joins'] ); - foreach ( $res as $row ) { - $authors[] = $row->rev_user_text; - } return $authors; } diff --git a/includes/actions/InfoAction.php b/includes/actions/InfoAction.php index 1165a268ef..0988f73452 100644 --- a/includes/actions/InfoAction.php +++ b/includes/actions/InfoAction.php @@ -718,6 +718,8 @@ class InfoAction extends FormlessAction { self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ), WANObjectCache::TTL_WEEK, function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) { + global $wgActorTableSchemaMigrationStage; + $title = $page->getTitle(); $id = $title->getArticleID(); @@ -725,6 +727,29 @@ class InfoAction extends FormlessAction { $dbrWatchlist = wfGetDB( DB_REPLICA, 'watchlist' ); $setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist ); + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $tables = [ 'revision_actor_temp' ]; + $field = 'revactor_actor'; + $pageField = 'revactor_page'; + $tsField = 'revactor_timestamp'; + $joins = []; + } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $tables = [ 'revision' ]; + $field = 'rev_user_text'; + $pageField = 'rev_page'; + $tsField = 'rev_timestamp'; + $joins = []; + } else { + $tables = [ 'revision', 'revision_actor_temp', 'actor' ]; + $field = 'COALESCE( actor_name, rev_user_text)'; + $pageField = 'rev_page'; + $tsField = 'rev_timestamp'; + $joins = [ + 'revision_actor_temp' => [ 'LEFT JOIN', 'revactor_rev = rev_id' ], + 'actor' => [ 'LEFT JOIN', 'revactor_actor = actor_id' ], + ]; + } + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); $result = []; @@ -752,10 +777,12 @@ class InfoAction extends FormlessAction { $result['authors'] = 0; } else { $result['authors'] = (int)$dbr->selectField( - 'revision', - 'COUNT(DISTINCT rev_user_text)', - [ 'rev_page' => $id ], - $fname + $tables, + "COUNT(DISTINCT $field)", + [ $pageField => $id ], + $fname, + [], + $joins ); } @@ -776,13 +803,15 @@ class InfoAction extends FormlessAction { // Recent number of distinct authors $result['recent_authors'] = (int)$dbr->selectField( - 'revision', - 'COUNT(DISTINCT rev_user_text)', + $tables, + "COUNT(DISTINCT $field)", [ - 'rev_page' => $id, - "rev_timestamp >= " . $dbr->addQuotes( $threshold ) + $pageField => $id, + "$tsField >= " . $dbr->addQuotes( $threshold ) ], - $fname + $fname, + [], + $joins ); // Subpages (if enabled) diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 228d319f18..73315a0966 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -1615,6 +1615,10 @@ abstract class ApiBase extends ContextSource { * @return string Validated and normalized parameter */ private function validateUser( $value, $encParamName ) { + if ( ExternalUserNames::isExternal( $value ) && User::newFromName( $value, false ) ) { + return $value; + } + $title = Title::makeTitleSafe( NS_USER, $value ); if ( $title === null || $title->hasFragment() ) { $this->dieWithError( diff --git a/includes/api/ApiCSPReport.php b/includes/api/ApiCSPReport.php index 0df0ca97d1..af040d153a 100644 --- a/includes/api/ApiCSPReport.php +++ b/includes/api/ApiCSPReport.php @@ -162,7 +162,7 @@ class ApiCSPReport extends ApiBase { private function generateLogLine( $flags, $report ) { $flagText = ''; if ( $flags ) { - $flagText = '[' . implode( $flags, ', ' ) . ']'; + $flagText = '[' . implode( ', ', $flags ) . ']'; } $blockedFile = isset( $report['blocked-uri'] ) ? $report['blocked-uri'] : 'n/a'; diff --git a/includes/api/ApiQueryAllDeletedRevisions.php b/includes/api/ApiQueryAllDeletedRevisions.php index 32d081e439..f885b729b1 100644 --- a/includes/api/ApiQueryAllDeletedRevisions.php +++ b/includes/api/ApiQueryAllDeletedRevisions.php @@ -224,10 +224,19 @@ class ApiQueryAllDeletedRevisions extends ApiQueryRevisionsBase { } if ( !is_null( $params['user'] ) ) { - $this->addWhereFld( 'ar_user_text', $params['user'] ); + // Don't query by user ID here, it might be able to use the ar_usertext_timestamp index. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['user'], false ), false ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } elseif ( !is_null( $params['excludeuser'] ) ) { - $this->addWhere( 'ar_user_text != ' . - $db->addQuotes( $params['excludeuser'] ) ); + // Here there's no chance of using ar_usertext_timestamp. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) { diff --git a/includes/api/ApiQueryAllImages.php b/includes/api/ApiQueryAllImages.php index dde22d8208..14f1cc4f84 100644 --- a/includes/api/ApiQueryAllImages.php +++ b/includes/api/ApiQueryAllImages.php @@ -85,7 +85,6 @@ class ApiQueryAllImages extends ApiQueryGeneratorBase { $db = $this->getDB(); $params = $this->extractRequestParams(); - $userId = !is_null( $params['user'] ) ? User::idFromName( $params['user'] ) : null; // Table and return fields $prop = array_flip( $params['prop'] ); @@ -192,19 +191,22 @@ class ApiQueryAllImages extends ApiQueryGeneratorBase { // Image filters if ( !is_null( $params['user'] ) ) { - if ( $userId ) { - $this->addWhereFld( 'img_user', $userId ); - } else { - $this->addWhereFld( 'img_user_text', $params['user'] ); - } + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'img_user', User::newFromName( $params['user'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } if ( $params['filterbots'] != 'all' ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'img_user' ); + $this->addTables( $actorQuery['tables'] ); $this->addTables( 'user_groups' ); + $this->addJoinConds( $actorQuery['joins'] ); $this->addJoinConds( [ 'user_groups' => [ 'LEFT JOIN', [ 'ug_group' => User::getGroupsWithPermission( 'bot' ), - 'ug_user = img_user', + 'ug_user = ' . $actorQuery['fields']['img_user'], 'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() ) ] ] ] ); @@ -273,15 +275,6 @@ class ApiQueryAllImages extends ApiQueryGeneratorBase { } if ( $params['sort'] == 'timestamp' ) { $this->addOption( 'ORDER BY', 'img_timestamp' . $sortFlag ); - if ( !is_null( $params['user'] ) ) { - if ( $userId ) { - $this->addOption( 'USE INDEX', [ 'image' => 'img_user_timestamp' ] ); - } else { - $this->addOption( 'USE INDEX', [ 'image' => 'img_usertext_timestamp' ] ); - } - } else { - $this->addOption( 'USE INDEX', [ 'image' => 'img_timestamp' ] ); - } } else { $this->addOption( 'ORDER BY', 'img_name' . $sortFlag ); } diff --git a/includes/api/ApiQueryAllRevisions.php b/includes/api/ApiQueryAllRevisions.php index 68236465d1..3af24597c2 100644 --- a/includes/api/ApiQueryAllRevisions.php +++ b/includes/api/ApiQueryAllRevisions.php @@ -104,19 +104,17 @@ class ApiQueryAllRevisions extends ApiQueryRevisionsBase { } if ( $params['user'] !== null ) { - $id = User::idFromName( $params['user'] ); - if ( $id ) { - $this->addWhereFld( 'rev_user', $id ); - } else { - $this->addWhereFld( 'rev_user_text', $params['user'] ); - } + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'rev_user', User::newFromName( $params['user'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } elseif ( $params['excludeuser'] !== null ) { - $id = User::idFromName( $params['excludeuser'] ); - if ( $id ) { - $this->addWhere( 'rev_user != ' . $id ); - } else { - $this->addWhere( 'rev_user_text != ' . $db->addQuotes( $params['excludeuser'] ) ); - } + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'rev_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } if ( $params['user'] !== null || $params['excludeuser'] !== null ) { diff --git a/includes/api/ApiQueryAllUsers.php b/includes/api/ApiQueryAllUsers.php index 26844f3f26..9652f810f7 100644 --- a/includes/api/ApiQueryAllUsers.php +++ b/includes/api/ApiQueryAllUsers.php @@ -41,6 +41,8 @@ class ApiQueryAllUsers extends ApiQueryBase { } public function execute() { + global $wgActorTableSchemaMigrationStage; + $params = $this->extractRequestParams(); $activeUserDays = $this->getConfig()->get( 'ActiveUserDays' ); @@ -178,17 +180,36 @@ class ApiQueryAllUsers extends ApiQueryBase { ] ] ); // Actually count the actions using a subquery (T66505 and T66507) + $tables = [ 'recentchanges' ]; + $joins = []; + if ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $userCond = 'rc_user_text = user_name'; + } else { + $tables[] = 'actor'; + $joins['actor'] = [ + $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN', + 'rc_actor = actor_id' + ]; + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $userCond = 'actor_user = user_id'; + } else { + $userCond = 'actor_user = user_id OR (rc_actor = 0 AND rc_user_text = user_name)'; + } + } $timestamp = $db->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds ); $this->addFields( [ 'recentactions' => '(' . $db->selectSQLText( - 'recentchanges', + $tables, 'COUNT(*)', [ - 'rc_user_text = user_name', + $userCond, 'rc_type != ' . $db->addQuotes( RC_EXTERNAL ), // no wikidata 'rc_log_type IS NULL OR rc_log_type != ' . $db->addQuotes( 'newusers' ), 'rc_timestamp >= ' . $db->addQuotes( $timestamp ), - ] + ], + __METHOD__, + [], + $joins ) . ')' ] ); } diff --git a/includes/api/ApiQueryBase.php b/includes/api/ApiQueryBase.php index 84169cb843..3ad45bbbde 100644 --- a/includes/api/ApiQueryBase.php +++ b/includes/api/ApiQueryBase.php @@ -446,11 +446,13 @@ abstract class ApiQueryBase extends ApiBase { if ( $showBlockInfo ) { $this->addFields( [ 'ipb_id', - 'ipb_by', - 'ipb_by_text', 'ipb_expiry', 'ipb_timestamp' ] ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'ipb_by' ); + $this->addTables( $actorQuery['tables'] ); + $this->addFields( $actorQuery['fields'] ); + $this->addJoinConds( $actorQuery['joins'] ); $commentQuery = CommentStore::getStore()->getJoin( 'ipb_reason' ); $this->addTables( $commentQuery['tables'] ); $this->addFields( $commentQuery['fields'] ); diff --git a/includes/api/ApiQueryBlocks.php b/includes/api/ApiQueryBlocks.php index 10695b30ca..08c13e7aab 100644 --- a/includes/api/ApiQueryBlocks.php +++ b/includes/api/ApiQueryBlocks.php @@ -55,8 +55,12 @@ class ApiQueryBlocks extends ApiQueryBase { $this->addFields( [ 'ipb_auto', 'ipb_id', 'ipb_timestamp' ] ); $this->addFieldsIf( [ 'ipb_address', 'ipb_user' ], $fld_user || $fld_userid ); - $this->addFieldsIf( 'ipb_by_text', $fld_by ); - $this->addFieldsIf( 'ipb_by', $fld_byid ); + if ( $fld_by || $fld_byid ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'ipb_by' ); + $this->addTables( $actorQuery['tables'] ); + $this->addFields( $actorQuery['fields'] ); + $this->addJoinConds( $actorQuery['joins'] ); + } $this->addFieldsIf( 'ipb_expiry', $fld_expiry ); $this->addFieldsIf( [ 'ipb_range_start', 'ipb_range_end' ], $fld_range ); $this->addFieldsIf( [ 'ipb_anon_only', 'ipb_create_account', 'ipb_enable_autoblock', diff --git a/includes/api/ApiQueryContributors.php b/includes/api/ApiQueryContributors.php index 25b7c84028..d07df5a370 100644 --- a/includes/api/ApiQueryContributors.php +++ b/includes/api/ApiQueryContributors.php @@ -43,6 +43,8 @@ class ApiQueryContributors extends ApiQueryBase { } public function execute() { + global $wgActorTableSchemaMigrationStage; + $db = $this->getDB(); $params = $this->extractRequestParams(); $this->requireMaxOneParameter( $params, 'group', 'excludegroup', 'rights', 'excluderights' ); @@ -73,17 +75,27 @@ class ApiQueryContributors extends ApiQueryBase { } $result = $this->getResult(); + $revQuery = Revision::getQueryInfo(); + + // For MIGRATION_NEW, target indexes on the revision_actor_temp table. + // Otherwise, revision is fine because it'll have to check all revision rows anyway. + $pageField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'revactor_page' : 'rev_page'; + $idField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW + ? 'revactor_actor' : $revQuery['fields']['rev_user']; + $countField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW + ? 'revactor_actor' : $revQuery['fields']['rev_user_text']; // First, count anons - $this->addTables( 'revision' ); + $this->addTables( $revQuery['tables'] ); + $this->addJoinConds( $revQuery['joins'] ); $this->addFields( [ - 'page' => 'rev_page', - 'anons' => 'COUNT(DISTINCT rev_user_text)', + 'page' => $pageField, + 'anons' => "COUNT(DISTINCT $countField)", ] ); - $this->addWhereFld( 'rev_page', $pages ); - $this->addWhere( 'rev_user = 0' ); + $this->addWhereFld( $pageField, $pages ); + $this->addWhere( ActorMigration::newMigration()->isAnon( $revQuery['fields']['rev_user'] ) ); $this->addWhere( $db->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' ); - $this->addOption( 'GROUP BY', 'rev_page' ); + $this->addOption( 'GROUP BY', $pageField ); $res = $this->select( __METHOD__ ); foreach ( $res as $row ) { $fit = $result->addValue( [ 'query', 'pages', $row->page ], @@ -103,24 +115,27 @@ class ApiQueryContributors extends ApiQueryBase { // Next, add logged-in users $this->resetQueryParams(); - $this->addTables( 'revision' ); + $this->addTables( $revQuery['tables'] ); + $this->addJoinConds( $revQuery['joins'] ); $this->addFields( [ - 'page' => 'rev_page', - 'user' => 'rev_user', - 'username' => 'MAX(rev_user_text)', // Non-MySQL databases don't like partial group-by + 'page' => $pageField, + 'id' => $idField, + // Non-MySQL databases don't like partial group-by + 'userid' => 'MAX(' . $revQuery['fields']['rev_user'] . ')', + 'username' => 'MAX(' . $revQuery['fields']['rev_user_text'] . ')', ] ); - $this->addWhereFld( 'rev_page', $pages ); - $this->addWhere( 'rev_user != 0' ); + $this->addWhereFld( $pageField, $pages ); + $this->addWhere( ActorMigration::newMigration()->isNotAnon( $revQuery['fields']['rev_user'] ) ); $this->addWhere( $db->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' ); - $this->addOption( 'GROUP BY', 'rev_page, rev_user' ); + $this->addOption( 'GROUP BY', [ $pageField, $idField ] ); $this->addOption( 'LIMIT', $params['limit'] + 1 ); // Force a sort order to ensure that properties are grouped by page - // But only if pp_page is not constant in the WHERE clause. + // But only if rev_page is not constant in the WHERE clause. if ( count( $pages ) > 1 ) { - $this->addOption( 'ORDER BY', 'rev_page, rev_user' ); + $this->addOption( 'ORDER BY', [ 'page', 'id' ] ); } else { - $this->addOption( 'ORDER BY', 'rev_user' ); + $this->addOption( 'ORDER BY', 'id' ); } $limitGroups = []; @@ -159,7 +174,7 @@ class ApiQueryContributors extends ApiQueryBase { $this->addJoinConds( [ 'user_groups' => [ $excludeGroups ? 'LEFT OUTER JOIN' : 'INNER JOIN', [ - 'ug_user=rev_user', + 'ug_user=' . $revQuery['fields']['rev_user'], 'ug_group' => $limitGroups, 'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() ) ] @@ -171,11 +186,11 @@ class ApiQueryContributors extends ApiQueryBase { $cont = explode( '|', $params['continue'] ); $this->dieContinueUsageIf( count( $cont ) != 2 ); $cont_page = (int)$cont[0]; - $cont_user = (int)$cont[1]; + $cont_id = (int)$cont[1]; $this->addWhere( - "rev_page > $cont_page OR " . - "(rev_page = $cont_page AND " . - "rev_user >= $cont_user)" + "$pageField > $cont_page OR " . + "($pageField = $cont_page AND " . + "$idField >= $cont_id)" ); } @@ -185,18 +200,16 @@ class ApiQueryContributors extends ApiQueryBase { if ( ++$count > $params['limit'] ) { // We've reached the one extra which shows that // there are additional pages to be had. Stop here... - $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->user ); - + $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->id ); return; } $fit = $this->addPageSubItem( $row->page, - [ 'userid' => (int)$row->user, 'name' => $row->username ], + [ 'userid' => (int)$row->userid, 'name' => $row->username ], 'user' ); if ( !$fit ) { - $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->user ); - + $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->id ); return; } } diff --git a/includes/api/ApiQueryDeletedRevisions.php b/includes/api/ApiQueryDeletedRevisions.php index f57906593a..b7fd8d4ddc 100644 --- a/includes/api/ApiQueryDeletedRevisions.php +++ b/includes/api/ApiQueryDeletedRevisions.php @@ -117,10 +117,19 @@ class ApiQueryDeletedRevisions extends ApiQueryRevisionsBase { } if ( !is_null( $params['user'] ) ) { - $this->addWhereFld( 'ar_user_text', $params['user'] ); + // Don't query by user ID here, it might be able to use the ar_usertext_timestamp index. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['user'], false ), false ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } elseif ( !is_null( $params['excludeuser'] ) ) { - $this->addWhere( 'ar_user_text != ' . - $db->addQuotes( $params['excludeuser'] ) ); + // Here there's no chance of using ar_usertext_timestamp. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) { diff --git a/includes/api/ApiQueryDeletedrevs.php b/includes/api/ApiQueryDeletedrevs.php index 6e6757ecd1..2d5074178e 100644 --- a/includes/api/ApiQueryDeletedrevs.php +++ b/includes/api/ApiQueryDeletedrevs.php @@ -110,8 +110,12 @@ class ApiQueryDeletedrevs extends ApiQueryBase { $this->addFieldsIf( 'ar_parent_id', $fld_parentid ); $this->addFieldsIf( 'ar_rev_id', $fld_revid ); - $this->addFieldsIf( 'ar_user_text', $fld_user ); - $this->addFieldsIf( 'ar_user', $fld_userid ); + if ( $fld_user || $fld_userid ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'ar_user' ); + $this->addTables( $actorQuery['tables'] ); + $this->addFields( $actorQuery['fields'] ); + $this->addJoinConds( $actorQuery['joins'] ); + } $this->addFieldsIf( 'ar_minor_edit', $fld_minor ); $this->addFieldsIf( 'ar_len', $fld_len ); $this->addFieldsIf( 'ar_sha1', $fld_sha1 ); @@ -199,10 +203,19 @@ class ApiQueryDeletedrevs extends ApiQueryBase { } if ( !is_null( $params['user'] ) ) { - $this->addWhereFld( 'ar_user_text', $params['user'] ); + // Don't query by user ID here, it might be able to use the ar_usertext_timestamp index. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['user'], false ), false ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } elseif ( !is_null( $params['excludeuser'] ) ) { - $this->addWhere( 'ar_user_text != ' . - $db->addQuotes( $params['excludeuser'] ) ); + // Here there's no chance of using ar_usertext_timestamp. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'ar_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) { @@ -251,10 +264,6 @@ class ApiQueryDeletedrevs extends ApiQueryBase { } $this->addOption( 'LIMIT', $limit + 1 ); - $this->addOption( - 'USE INDEX', - [ 'archive' => ( $mode == 'user' ? 'ar_usertext_timestamp' : 'name_title_timestamp' ) ] - ); if ( $mode == 'all' ) { if ( $params['unique'] ) { // @todo Does this work on non-MySQL? diff --git a/includes/api/ApiQueryLogEvents.php b/includes/api/ApiQueryLogEvents.php index f3453001c4..84e12d7dfa 100644 --- a/includes/api/ApiQueryLogEvents.php +++ b/includes/api/ApiQueryLogEvents.php @@ -62,11 +62,15 @@ class ApiQueryLogEvents extends ApiQueryBase { $this->addWhere( $hideLogs ); } - // Order is significant here - $this->addTables( [ 'logging', 'user', 'page' ] ); + $actorMigration = ActorMigration::newMigration(); + $actorQuery = $actorMigration->getJoin( 'log_user' ); + $this->addTables( 'logging' ); + $this->addTables( $actorQuery['tables'] ); + $this->addTables( [ 'user', 'page' ] ); + $this->addJoinConds( $actorQuery['joins'] ); $this->addJoinConds( [ 'user' => [ 'LEFT JOIN', - 'user_id=log_user' ], + 'user_id=' . $actorQuery['fields']['log_user'] ], 'page' => [ 'LEFT JOIN', [ 'log_namespace=page_namespace', 'log_title=page_title' ] ] ] ); @@ -84,8 +88,8 @@ class ApiQueryLogEvents extends ApiQueryBase { // join at query time. This leads to different results in various // scenarios, e.g. deletion, recreation. $this->addFieldsIf( 'log_page', $this->fld_ids ); - $this->addFieldsIf( [ 'log_user', 'log_user_text', 'user_name' ], $this->fld_user ); - $this->addFieldsIf( 'log_user', $this->fld_userid ); + $this->addFieldsIf( $actorQuery['fields'] + [ 'user_name' ], $this->fld_user ); + $this->addFieldsIf( $actorQuery['fields'], $this->fld_userid ); $this->addFieldsIf( [ 'log_namespace', 'log_title' ], $this->fld_title || $this->fld_parsedcomment @@ -166,12 +170,12 @@ class ApiQueryLogEvents extends ApiQueryBase { $user = $params['user']; if ( !is_null( $user ) ) { - $userid = User::idFromName( $user ); - if ( $userid ) { - $this->addWhereFld( 'log_user', $userid ); - } else { - $this->addWhereFld( 'log_user_text', $user ); - } + // Note the joins in $q are the same as those from ->getJoin() above + // so we only need to add 'conds' here. + $q = $actorMigration->getWhere( + $db, 'log_user', User::newFromName( $params['user'], false ) + ); + $this->addWhere( $q['conds'] ); } $title = $params['title']; diff --git a/includes/api/ApiQueryRecentChanges.php b/includes/api/ApiQueryRecentChanges.php index e289e42d9c..e431202bc8 100644 --- a/includes/api/ApiQueryRecentChanges.php +++ b/includes/api/ApiQueryRecentChanges.php @@ -211,8 +211,18 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase { $this->addWhereIf( 'rc_minor != 0', isset( $show['minor'] ) ); $this->addWhereIf( 'rc_bot = 0', isset( $show['!bot'] ) ); $this->addWhereIf( 'rc_bot != 0', isset( $show['bot'] ) ); - $this->addWhereIf( 'rc_user = 0', isset( $show['anon'] ) ); - $this->addWhereIf( 'rc_user != 0', isset( $show['!anon'] ) ); + if ( isset( $show['anon'] ) || isset( $show['!anon'] ) ) { + $actorMigration = ActorMigration::newMigration(); + $actorQuery = $actorMigration->getJoin( 'rc_user' ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhereIf( + $actorMigration->isAnon( $actorQuery['fields']['rc_user'] ), isset( $show['anon'] ) + ); + $this->addWhereIf( + $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] ), isset( $show['!anon'] ) + ); + } $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) ); $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) ); $this->addWhereIf( 'page_is_redirect = 1', isset( $show['redirect'] ) ); @@ -237,14 +247,21 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase { $this->requireMaxOneParameter( $params, 'user', 'excludeuser' ); if ( !is_null( $params['user'] ) ) { - $this->addWhereFld( 'rc_user_text', $params['user'] ); + // Don't query by user ID here, it might be able to use the rc_user_text index. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $this->getDB(), 'rc_user', User::newFromName( $params['user'], false ), false ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } if ( !is_null( $params['excludeuser'] ) ) { - // We don't use the rc_user_text index here because - // * it would require us to sort by rc_user_text before rc_timestamp - // * the != condition doesn't throw out too many rows anyway - $this->addWhere( 'rc_user_text != ' . $this->getDB()->addQuotes( $params['excludeuser'] ) ); + // Here there's no chance to use the rc_user_text index, so allow ID to be used. + $actorQuery = ActorMigration::newMigration() + ->getWhere( $this->getDB(), 'rc_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } /* Add the fields we're concerned with to our query. */ @@ -272,8 +289,12 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase { /* Add fields to our query if they are specified as a needed parameter. */ $this->addFieldsIf( [ 'rc_this_oldid', 'rc_last_oldid' ], $this->fld_ids ); - $this->addFieldsIf( 'rc_user', $this->fld_user || $this->fld_userid ); - $this->addFieldsIf( 'rc_user_text', $this->fld_user ); + if ( $this->fld_user || $this->fld_userid ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); + $this->addTables( $actorQuery['tables'] ); + $this->addFields( $actorQuery['fields'] ); + $this->addJoinConds( $actorQuery['joins'] ); + } $this->addFieldsIf( [ 'rc_minor', 'rc_type', 'rc_bot' ], $this->fld_flags ); $this->addFieldsIf( [ 'rc_old_len', 'rc_new_len' ], $this->fld_sizes ); $this->addFieldsIf( [ 'rc_patrolled', 'rc_log_type' ], $this->fld_patrolled ); diff --git a/includes/api/ApiQueryRevisions.php b/includes/api/ApiQueryRevisions.php index ef0223aae5..5858bc726d 100644 --- a/includes/api/ApiQueryRevisions.php +++ b/includes/api/ApiQueryRevisions.php @@ -286,20 +286,17 @@ class ApiQueryRevisions extends ApiQueryRevisionsBase { $this->addWhereFld( 'rev_page', reset( $ids ) ); if ( $params['user'] !== null ) { - $user = User::newFromName( $params['user'] ); - if ( $user && $user->getId() > 0 ) { - $this->addWhereFld( 'rev_user', $user->getId() ); - } else { - $this->addWhereFld( 'rev_user_text', $params['user'] ); - } + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'rev_user', User::newFromName( $params['user'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( $actorQuery['conds'] ); } elseif ( $params['excludeuser'] !== null ) { - $user = User::newFromName( $params['excludeuser'] ); - if ( $user && $user->getId() > 0 ) { - $this->addWhere( 'rev_user != ' . $user->getId() ); - } else { - $this->addWhere( 'rev_user_text != ' . - $db->addQuotes( $params['excludeuser'] ) ); - } + $actorQuery = ActorMigration::newMigration() + ->getWhere( $db, 'rev_user', User::newFromName( $params['excludeuser'], false ) ); + $this->addTables( $actorQuery['tables'] ); + $this->addJoinConds( $actorQuery['joins'] ); + $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' ); } if ( $params['user'] !== null || $params['excludeuser'] !== null ) { // Paranoia: avoid brute force searches (T19342) diff --git a/includes/api/ApiQueryUserContributions.php b/includes/api/ApiQueryUserContributions.php index 1705c57361..bb4a2ef07e 100644 --- a/includes/api/ApiQueryUserContributions.php +++ b/includes/api/ApiQueryUserContributions.php @@ -31,13 +31,14 @@ class ApiQueryContributions extends ApiQueryBase { parent::__construct( $query, $moduleName, 'uc' ); } - private $params, $prefixMode, $userprefix, $multiUserMode, $idMode, $usernames, $userids, - $parentLens, $commentStore; + private $params, $multiUserMode, $orderBy, $parentLens; private $fld_ids = false, $fld_title = false, $fld_timestamp = false, $fld_comment = false, $fld_parsedcomment = false, $fld_flags = false, $fld_patrolled = false, $fld_tags = false, $fld_size = false, $fld_sizediff = false; public function execute() { + global $wgActorTableSchemaMigrationStage; + // Parse some parameters $this->params = $this->extractRequestParams(); @@ -63,36 +64,173 @@ class ApiQueryContributions extends ApiQueryBase { // TODO: if the query is going only against the revision table, should this be done? $this->selectNamedDB( 'contributions', DB_REPLICA, 'contributions' ); - $this->requireOnlyOneParameter( $this->params, 'userprefix', 'userids', 'user' ); + $sort = ( $this->params['dir'] == 'newer' ? '' : ' DESC' ); + $op = ( $this->params['dir'] == 'older' ? '<' : '>' ); - $this->idMode = false; + // Create an Iterator that produces the UserIdentity objects we need, depending + // on which of the 'userprefix', 'userids', or 'user' params was + // specified. + $this->requireOnlyOneParameter( $this->params, 'userprefix', 'userids', 'user' ); if ( isset( $this->params['userprefix'] ) ) { - $this->prefixMode = true; $this->multiUserMode = true; - $this->userprefix = $this->params['userprefix']; - } elseif ( isset( $this->params['userids'] ) ) { - $this->userids = []; + $this->orderBy = 'name'; + $fname = __METHOD__; + + // Because 'userprefix' might produce a huge number of users (e.g. + // a wiki with users "Test00000001" to "Test99999999"), use a + // generator with batched lookup and continuation. + $userIter = call_user_func( function () use ( $dbSecondary, $sort, $op, $fname ) { + global $wgActorTableSchemaMigrationStage; + + $from = $fromName = false; + if ( !is_null( $this->params['continue'] ) ) { + $continue = explode( '|', $this->params['continue'] ); + $this->dieContinueUsageIf( count( $continue ) != 4 ); + $this->dieContinueUsageIf( $continue[0] !== 'name' ); + $fromName = $continue[1]; + $from = "$op= " . $dbSecondary->addQuotes( $fromName ); + } + $like = $dbSecondary->buildLike( $this->params['userprefix'], $dbSecondary->anyString() ); + + $limit = 501; + + do { + // For the new schema, pull from the actor table. For the + // old, pull from rev_user. For migration a FULL [OUTER] + // JOIN would be what we want, except MySQL doesn't support + // that so we have to UNION instead. + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $res = $dbSecondary->select( + 'actor', + [ 'actor_id', 'user_id' => 'COALESCE(actor_user,0)', 'user_name' => 'actor_name' ], + array_merge( [ "actor_name$like" ], $from ? [ "actor_name $from" ] : [] ), + $fname, + [ 'ORDER BY' => [ "user_name $sort" ], 'LIMIT' => $limit ] + ); + } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $res = $dbSecondary->select( + 'revision', + [ 'actor_id' => 'NULL', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ], + array_merge( [ "rev_user_text$like" ], $from ? [ "rev_user_text $from" ] : [] ), + $fname, + [ 'DISTINCT', 'ORDER BY' => [ "rev_user_text $sort" ], 'LIMIT' => $limit ] + ); + } else { + // There are three queries we have to combine to be sure of getting all results: + // - actor table (any rows that have been migrated will have empty rev_user_text) + // - revision+actor by user id + // - revision+actor by name for anons + $options = $dbSecondary->unionSupportsOrderAndLimit() + ? [ 'ORDER BY' => [ "user_name $sort" ], 'LIMIT' => $limit ] : []; + $subsql = []; + $subsql[] = $dbSecondary->selectSQLText( + 'actor', + [ 'actor_id', 'user_id' => 'COALESCE(actor_user,0)', 'user_name' => 'actor_name' ], + array_merge( [ "actor_name$like" ], $from ? [ "actor_name $from" ] : [] ), + $fname, + $options + ); + $subsql[] = $dbSecondary->selectSQLText( + [ 'revision', 'actor' ], + [ 'actor_id', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ], + array_merge( + [ "rev_user_text$like", 'rev_user != 0' ], + $from ? [ "rev_user_text $from" ] : [] + ), + $fname, + array_merge( [ 'DISTINCT' ], $options ), + [ 'actor' => [ 'LEFT JOIN', 'rev_user = actor_user' ] ] + ); + $subsql[] = $dbSecondary->selectSQLText( + [ 'revision', 'actor' ], + [ 'actor_id', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ], + array_merge( + [ "rev_user_text$like", 'rev_user = 0' ], + $from ? [ "rev_user_text $from" ] : [] + ), + $fname, + array_merge( [ 'DISTINCT' ], $options ), + [ 'actor' => [ 'LEFT JOIN', 'rev_user_text = actor_name' ] ] + ); + $sql = $dbSecondary->unionQueries( $subsql, false ) . " ORDER BY user_name $sort"; + $sql = $dbSecondary->limitResult( $sql, $limit ); + $res = $dbSecondary->query( $sql, $fname ); + } + $count = 0; + $from = null; + foreach ( $res as $row ) { + if ( ++$count >= $limit ) { + $from = $row->user_name; + break; + } + yield User::newFromRow( $row ); + } + } while ( $from !== null ); + } ); + // Do the actual sorting client-side, because otherwise + // prepareQuery might try to sort by actor and confuse everything. + $batchSize = 1; + } elseif ( isset( $this->params['userids'] ) ) { if ( !count( $this->params['userids'] ) ) { $encParamName = $this->encodeParamName( 'userids' ); $this->dieWithError( [ 'apierror-paramempty', $encParamName ], "paramempty_$encParamName" ); } + $ids = []; foreach ( $this->params['userids'] as $uid ) { if ( $uid <= 0 ) { $this->dieWithError( [ 'apierror-invaliduserid', $uid ], 'invaliduserid' ); } + $ids[] = $uid; + } + + $this->orderBy = 'id'; + $this->multiUserMode = count( $ids ) > 1; - $this->userids[] = $uid; + $from = $fromId = false; + if ( $this->multiUserMode && !is_null( $this->params['continue'] ) ) { + $continue = explode( '|', $this->params['continue'] ); + $this->dieContinueUsageIf( count( $continue ) != 4 ); + $this->dieContinueUsageIf( $continue[0] !== 'id' && $continue[0] !== 'actor' ); + $fromId = (int)$continue[1]; + $this->dieContinueUsageIf( $continue[1] !== (string)$fromId ); + $from = "$op= $fromId"; } - $this->prefixMode = false; - $this->multiUserMode = ( count( $this->params['userids'] ) > 1 ); - $this->idMode = true; + // For the new schema, just select from the actor table. For the + // old and transitional schemas, select from user and left join + // actor if it exists. + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $res = $dbSecondary->select( + 'actor', + [ 'actor_id', 'user_id' => 'actor_user', 'user_name' => 'actor_name' ], + array_merge( [ 'actor_user' => $ids ], $from ? [ "actor_id $from" ] : [] ), + __METHOD__, + [ 'ORDER BY' => "user_id $sort" ] + ); + } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $res = $dbSecondary->select( + 'user', + [ 'actor_id' => 'NULL', 'user_id' => 'user_id', 'user_name' => 'user_name' ], + array_merge( [ 'user_id' => $ids ], $from ? [ "user_id $from" ] : [] ), + __METHOD__, + [ 'ORDER BY' => "user_id $sort" ] + ); + } else { + $res = $dbSecondary->select( + [ 'user', 'actor' ], + [ 'actor_id', 'user_id', 'user_name' ], + array_merge( [ 'user_id' => $ids ], $from ? [ "user_id $from" ] : [] ), + __METHOD__, + [ 'ORDER BY' => "user_id $sort" ], + [ 'actor' => [ 'LEFT JOIN', 'actor_user = user_id' ] ] + ); + } + $userIter = UserArray::newFromResult( $res ); + $batchSize = count( $ids ); } else { - $anyIPs = false; - $this->userids = []; - $this->usernames = []; + $names = []; if ( !count( $this->params['user'] ) ) { $encParamName = $this->encodeParamName( 'user' ); $this->dieWithError( @@ -107,9 +245,8 @@ class ApiQueryContributions extends ApiQueryBase { ); } - if ( User::isIP( $u ) ) { - $anyIPs = true; - $this->usernames[] = $u; + if ( User::isIP( $u ) || ExternalUserNames::isExternal( $u ) ) { + $names[$u] = null; } else { $name = User::getCanonicalName( $u, 'valid' ); if ( $name === false ) { @@ -118,94 +255,218 @@ class ApiQueryContributions extends ApiQueryBase { [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $u ) ], "baduser_$encParamName" ); } - $this->usernames[] = $name; + $names[$name] = null; } } - $this->prefixMode = false; - $this->multiUserMode = ( count( $this->params['user'] ) > 1 ); - if ( !$anyIPs ) { - $dbr = $this->getDB(); - $res = $dbr->select( 'user', 'user_id', [ 'user_name' => $this->usernames ], __METHOD__ ); + $this->orderBy = 'name'; + $this->multiUserMode = count( $names ) > 1; + + $from = $fromName = false; + if ( $this->multiUserMode && !is_null( $this->params['continue'] ) ) { + $continue = explode( '|', $this->params['continue'] ); + $this->dieContinueUsageIf( count( $continue ) != 4 ); + $this->dieContinueUsageIf( $continue[0] !== 'name' && $continue[0] !== 'actor' ); + $fromName = $continue[1]; + $from = "$op= " . $dbSecondary->addQuotes( $fromName ); + } + + // For the new schema, just select from the actor table. For the + // old and transitional schemas, select from user and left join + // actor if it exists then merge in any unknown users (IPs and imports). + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $res = $dbSecondary->select( + 'actor', + [ 'actor_id', 'user_id' => 'actor_user', 'user_name' => 'actor_name' ], + array_merge( [ 'actor_name' => array_keys( $names ) ], $from ? [ "actor_id $from" ] : [] ), + __METHOD__, + [ 'ORDER BY' => "actor_name $sort" ] + ); + $userIter = UserArray::newFromResult( $res ); + } else { + if ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $res = $dbSecondary->select( + 'user', + [ 'actor_id' => 'NULL', 'user_id', 'user_name' ], + array_merge( [ 'user_name' => array_keys( $names ) ], $from ? [ "user_name $from" ] : [] ), + __METHOD__ + ); + } else { + $res = $dbSecondary->select( + [ 'user', 'actor' ], + [ 'actor_id', 'user_id', 'user_name' ], + array_merge( [ 'user_name' => array_keys( $names ) ], $from ? [ "user_name $from" ] : [] ), + __METHOD__, + [], + [ 'actor' => [ 'LEFT JOIN', 'actor_user = user_id' ] ] + ); + } foreach ( $res as $row ) { - $this->userids[] = $row->user_id; + $names[$row->user_name] = $row; } - $this->idMode = count( $this->userids ) === count( $this->usernames ); + call_user_func_array( + $this->params['dir'] == 'newer' ? 'ksort' : 'krsort', [ &$names, SORT_STRING ] + ); + $neg = $op === '>' ? -1 : 1; + $userIter = call_user_func( function () use ( $names, $fromName, $neg ) { + foreach ( $names as $name => $row ) { + if ( $fromName === false || $neg * strcmp( $name, $fromName ) <= 0 ) { + $user = $row ? User::newFromRow( $row ) : User::newFromName( $name, false ); + yield $user; + } + } + } ); } + $batchSize = count( $names ); } - $this->prepareQuery(); - - $hookData = []; - // Do the actual query. - $res = $this->select( __METHOD__, [], $hookData ); + // During migration, force ordering on the client side because we're + // having to combine multiple queries that would otherwise have + // different sort orders. + if ( $wgActorTableSchemaMigrationStage === MIGRATION_WRITE_BOTH || + $wgActorTableSchemaMigrationStage === MIGRATION_WRITE_NEW + ) { + $batchSize = 1; + } - if ( $this->fld_sizediff ) { - $revIds = []; - foreach ( $res as $row ) { - if ( $row->rev_parent_id ) { - $revIds[] = $row->rev_parent_id; - } - } - $this->parentLens = Revision::getParentLengths( $dbSecondary, $revIds ); - $res->rewind(); // reset + // With the new schema, the DB query will order by actor so update $this->orderBy to match. + if ( $batchSize > 1 && $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $this->orderBy = 'actor'; } - // Initialise some variables $count = 0; $limit = $this->params['limit']; + $userIter->rewind(); + while ( $userIter->valid() ) { + $users = []; + while ( count( $users ) < $batchSize && $userIter->valid() ) { + $users[] = $userIter->current(); + $userIter->next(); + } + + // Ugh. We have to run the query three times, once for each + // possible 'orcond' from ActorMigration, and then merge them all + // together in the proper order. And preserving the correct + // $hookData for each one. + // @todo When ActorMigration is removed, this can go back to a + // single prepare and select. + $merged = []; + foreach ( [ 'actor', 'userid', 'username' ] as $which ) { + if ( $this->prepareQuery( $users, $limit - $count, $which ) ) { + $hookData = []; + $res = $this->select( __METHOD__, [], $hookData ); + foreach ( $res as $row ) { + $merged[] = [ $row, &$hookData ]; + } + } + } + $neg = $this->params['dir'] == 'newer' ? 1 : -1; + usort( $merged, function ( $a, $b ) use ( $neg, $batchSize ) { + if ( $batchSize === 1 ) { // One user, can't be different + $ret = 0; + } elseif ( $this->orderBy === 'id' ) { + $ret = $a[0]->rev_user - $b[0]->rev_user; + } elseif ( $this->orderBy === 'name' ) { + $ret = strcmp( $a[0]->rev_user_text, $b[0]->rev_user_text ); + } else { + $ret = $a[0]->rev_actor - $b[0]->rev_actor; + } + + if ( !$ret ) { + $ret = strcmp( + wfTimestamp( TS_MW, $a[0]->rev_timestamp ), + wfTimestamp( TS_MW, $b[0]->rev_timestamp ) + ); + } + + if ( !$ret ) { + $ret = $a[0]->rev_id - $b[0]->rev_id; + } - // Fetch each row - foreach ( $res as $row ) { - if ( ++$count > $limit ) { - // We've reached the one extra which shows that there are - // additional pages to be had. Stop here... - $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) ); - break; + return $neg * $ret; + } ); + $merged = array_slice( $merged, 0, $limit - $count + 1 ); + // (end "Ugh") + + if ( $this->fld_sizediff ) { + $revIds = []; + foreach ( $merged as $data ) { + if ( $data[0]->rev_parent_id ) { + $revIds[] = $data[0]->rev_parent_id; + } + } + $this->parentLens = Revision::getParentLengths( $dbSecondary, $revIds ); } - $vals = $this->extractRowInfo( $row ); - $fit = $this->processRow( $row, $vals, $hookData ) && - $this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals ); - if ( !$fit ) { - $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) ); - break; + foreach ( $merged as $data ) { + $row = $data[0]; + $hookData = &$data[1]; + if ( ++$count > $limit ) { + // We've reached the one extra which shows that there are + // additional pages to be had. Stop here... + $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) ); + break 2; + } + + $vals = $this->extractRowInfo( $row ); + $fit = $this->processRow( $row, $vals, $hookData ) && + $this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals ); + if ( !$fit ) { + $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) ); + break 2; + } } } - $this->getResult()->addIndexedTagName( - [ 'query', $this->getModuleName() ], - 'item' - ); + $this->getResult()->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' ); } /** * Prepares the query and returns the limit of rows requested + * @param User[] $users + * @param int $limit + * @param string $which 'actor', 'userid', or 'username' + * @return bool */ - private function prepareQuery() { - // We're after the revision table, and the corresponding page - // row for anything we retrieve. We may also need the - // recentchanges row and/or tag summary row. - $user = $this->getUser(); - $tables = [ 'page', 'revision' ]; // Order may change - $this->addWhere( 'page_id=rev_page' ); + private function prepareQuery( array $users, $limit, $which ) { + global $wgActorTableSchemaMigrationStage; + + $this->resetQueryParams(); + $db = $this->getDB(); + + $revQuery = Revision::getQueryInfo( [ 'page' ] ); + $this->addTables( $revQuery['tables'] ); + $this->addJoinConds( $revQuery['joins'] ); + $this->addFields( $revQuery['fields'] ); + + $revWhere = ActorMigration::newMigration()->getWhere( $db, 'rev_user', $users ); + if ( !isset( $revWhere['orconds'][$which] ) ) { + return false; + } + $this->addWhere( $revWhere['orconds'][$which] ); + + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $orderUserField = 'rev_actor'; + $userField = $this->orderBy === 'actor' ? 'revactor_actor' : 'actor_name'; + } else { + $orderUserField = $this->orderBy === 'id' ? 'rev_user' : 'rev_user_text'; + $userField = $revQuery['fields'][$orderUserField]; + } + if ( $which === 'actor' ) { + $tsField = 'revactor_timestamp'; + $idField = 'revactor_rev'; + } else { + $tsField = 'rev_timestamp'; + $idField = 'rev_id'; + } // Handle continue parameter if ( !is_null( $this->params['continue'] ) ) { $continue = explode( '|', $this->params['continue'] ); - $db = $this->getDB(); if ( $this->multiUserMode ) { $this->dieContinueUsageIf( count( $continue ) != 4 ); $modeFlag = array_shift( $continue ); - $this->dieContinueUsageIf( !in_array( $modeFlag, [ 'id', 'name' ] ) ); - if ( $this->idMode && $modeFlag === 'name' ) { - // The users were created since this query started, but we - // can't go back and change modes now. So just keep on with - // name mode. - $this->idMode = false; - } - $this->dieContinueUsageIf( ( $modeFlag === 'id' ) !== $this->idMode ); - $userField = $this->idMode ? 'rev_user' : 'rev_user_text'; + $this->dieContinueUsageIf( $modeFlag !== $this->orderBy ); $encUser = $db->addQuotes( array_shift( $continue ) ); } else { $this->dieContinueUsageIf( count( $continue ) != 2 ); @@ -218,21 +479,22 @@ class ApiQueryContributions extends ApiQueryBase { $this->addWhere( "$userField $op $encUser OR " . "($userField = $encUser AND " . - "(rev_timestamp $op $encTS OR " . - "(rev_timestamp = $encTS AND " . - "rev_id $op= $encId)))" + "($tsField $op $encTS OR " . + "($tsField = $encTS AND " . + "$idField $op= $encId)))" ); } else { $this->addWhere( - "rev_timestamp $op $encTS OR " . - "(rev_timestamp = $encTS AND " . - "rev_id $op= $encId)" + "$tsField $op $encTS OR " . + "($tsField = $encTS AND " . + "$idField $op= $encId)" ); } } // Don't include any revisions where we're not supposed to be able to // see the username. + $user = $this->getUser(); if ( !$user->isAllowed( 'deletedhistory' ) ) { $bitmask = Revision::DELETED_USER; } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { @@ -241,29 +503,20 @@ class ApiQueryContributions extends ApiQueryBase { $bitmask = 0; } if ( $bitmask ) { - $this->addWhere( $this->getDB()->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); + $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); } - // We only want pages by the specified users. - if ( $this->prefixMode ) { - $this->addWhere( 'rev_user_text' . - $this->getDB()->buildLike( $this->userprefix, $this->getDB()->anyString() ) ); - } elseif ( $this->idMode ) { - $this->addWhereFld( 'rev_user', $this->userids ); - } else { - $this->addWhereFld( 'rev_user_text', $this->usernames ); - } - // ... and in the specified timeframe. - // Ensure the same sort order for rev_user/rev_user_text and rev_timestamp - // so our query is indexed - if ( $this->multiUserMode ) { - $this->addWhereRange( $this->idMode ? 'rev_user' : 'rev_user_text', - $this->params['dir'], null, null ); + // Add the user field to ORDER BY if there are multiple users + if ( count( $users ) > 1 ) { + $this->addWhereRange( $orderUserField, $this->params['dir'], null, null ); } - $this->addTimestampWhereRange( 'rev_timestamp', + + // Then timestamp + $this->addTimestampWhereRange( $tsField, $this->params['dir'], $this->params['start'], $this->params['end'] ); - // Include in ORDER BY for uniqueness - $this->addWhereRange( 'rev_id', $this->params['dir'], null, null ); + + // Then rev_id for a total ordering + $this->addWhereRange( $idField, $this->params['dir'], null, null ); $this->addWhereFld( 'page_namespace', $this->params['namespace'] ); @@ -286,25 +539,12 @@ class ApiQueryContributions extends ApiQueryBase { $this->addWhereIf( 'rev_minor_edit != 0', isset( $show['minor'] ) ); $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) ); $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) ); - $this->addWhereIf( 'rev_id != page_latest', isset( $show['!top'] ) ); - $this->addWhereIf( 'rev_id = page_latest', isset( $show['top'] ) ); + $this->addWhereIf( $idField . ' != page_latest', isset( $show['!top'] ) ); + $this->addWhereIf( $idField . ' = page_latest', isset( $show['top'] ) ); $this->addWhereIf( 'rev_parent_id != 0', isset( $show['!new'] ) ); $this->addWhereIf( 'rev_parent_id = 0', isset( $show['new'] ) ); } - $this->addOption( 'LIMIT', $this->params['limit'] + 1 ); - - // Mandatory fields: timestamp allows request continuation - // ns+title checks if the user has access rights for this page - // user_text is necessary if multiple users were specified - $this->addFields( [ - 'rev_id', - 'rev_timestamp', - 'page_namespace', - 'page_title', - 'rev_user', - 'rev_user_text', - 'rev_deleted' - ] ); + $this->addOption( 'LIMIT', $limit + 1 ); if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) || $this->fld_patrolled @@ -313,48 +553,25 @@ class ApiQueryContributions extends ApiQueryBase { $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'permissiondenied' ); } - // Use a redundant join condition on both - // timestamp and ID so we can use the timestamp - // index - $index['recentchanges'] = 'rc_user_text'; - if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ) { - // Put the tables in the right order for - // STRAIGHT_JOIN - $tables = [ 'revision', 'recentchanges', 'page' ]; - $this->addOption( 'STRAIGHT_JOIN' ); - $this->addWhere( 'rc_user_text=rev_user_text' ); - $this->addWhere( 'rc_timestamp=rev_timestamp' ); - $this->addWhere( 'rc_this_oldid=rev_id' ); - } else { - $tables[] = 'recentchanges'; - $this->addJoinConds( [ 'recentchanges' => [ - 'LEFT JOIN', [ - 'rc_user_text=rev_user_text', - 'rc_timestamp=rev_timestamp', - 'rc_this_oldid=rev_id' ] ] ] ); - } + $this->addTables( 'recentchanges' ); + $this->addJoinConds( [ 'recentchanges' => [ + isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ? 'JOIN' : 'LEFT JOIN', + [ + // This is a crazy hack. recentchanges has no index on rc_this_oldid, so instead of adding + // one T19237 did a join using rc_user_text and rc_timestamp instead. Now rc_user_text is + // probably unavailable, so just do rc_timestamp. + 'rc_timestamp = ' . $tsField, + 'rc_this_oldid = ' . $idField, + ] + ] ] ); } - $this->addTables( $tables ); - $this->addFieldsIf( 'rev_page', $this->fld_ids ); - $this->addFieldsIf( 'page_latest', $this->fld_flags ); - // $this->addFieldsIf( 'rev_text_id', $this->fld_ids ); // Should this field be exposed? - $this->addFieldsIf( 'rev_len', $this->fld_size || $this->fld_sizediff ); - $this->addFieldsIf( 'rev_minor_edit', $this->fld_flags ); - $this->addFieldsIf( 'rev_parent_id', $this->fld_flags || $this->fld_sizediff || $this->fld_ids ); $this->addFieldsIf( 'rc_patrolled', $this->fld_patrolled ); - if ( $this->fld_comment || $this->fld_parsedcomment ) { - $commentQuery = $this->commentStore->getJoin( 'rev_comment' ); - $this->addTables( $commentQuery['tables'] ); - $this->addFields( $commentQuery['fields'] ); - $this->addJoinConds( $commentQuery['joins'] ); - } - if ( $this->fld_tags ) { $this->addTables( 'tag_summary' ); $this->addJoinConds( - [ 'tag_summary' => [ 'LEFT JOIN', [ 'rev_id=ts_rev_id' ] ] ] + [ 'tag_summary' => [ 'LEFT JOIN', [ $idField . ' = ts_rev_id' ] ] ] ); $this->addFields( 'ts_tags' ); } @@ -362,14 +579,12 @@ class ApiQueryContributions extends ApiQueryBase { if ( isset( $this->params['tag'] ) ) { $this->addTables( 'change_tag' ); $this->addJoinConds( - [ 'change_tag' => [ 'INNER JOIN', [ 'rev_id=ct_rev_id' ] ] ] + [ 'change_tag' => [ 'INNER JOIN', [ $idField . ' = ct_rev_id' ] ] ] ); $this->addWhereFld( 'ct_tag', $this->params['tag'] ); } - if ( isset( $index ) ) { - $this->addOption( 'USE INDEX', $index ); - } + return true; } /** @@ -480,10 +695,13 @@ class ApiQueryContributions extends ApiQueryBase { private function continueStr( $row ) { if ( $this->multiUserMode ) { - if ( $this->idMode ) { - return "id|$row->rev_user|$row->rev_timestamp|$row->rev_id"; - } else { - return "name|$row->rev_user_text|$row->rev_timestamp|$row->rev_id"; + switch ( $this->orderBy ) { + case 'id': + return "id|$row->rev_user|$row->rev_timestamp|$row->rev_id"; + case 'name': + return "name|$row->rev_user_text|$row->rev_timestamp|$row->rev_id"; + case 'actor': + return "actor|$row->rev_actor|$row->rev_timestamp|$row->rev_id"; } } else { return "$row->rev_timestamp|$row->rev_id"; diff --git a/includes/api/ApiStashEdit.php b/includes/api/ApiStashEdit.php index b4b9321787..23163c2489 100644 --- a/includes/api/ApiStashEdit.php +++ b/includes/api/ApiStashEdit.php @@ -340,11 +340,15 @@ class ApiStashEdit extends ApiBase { * @return string|null TS_MW timestamp or null */ private static function lastEditTime( User $user ) { - $time = wfGetDB( DB_REPLICA )->selectField( - 'recentchanges', + $db = wfGetDB( DB_REPLICA ); + $actorQuery = ActorMigration::newMigration()->getWhere( $db, 'rc_user', $user, false ); + $time = $db->selectField( + [ 'recentchanges' ] + $actorQuery['tables'], 'MAX(rc_timestamp)', - [ 'rc_user_text' => $user->getName() ], - __METHOD__ + [ $actorQuery['conds'] ], + __METHOD__, + [], + $actorQuery['joins'] ); return wfTimestampOrNull( TS_MW, $time ); diff --git a/includes/api/i18n/de.json b/includes/api/i18n/de.json index bbee4fa6ef..b2cb6f730d 100644 --- a/includes/api/i18n/de.json +++ b/includes/api/i18n/de.json @@ -22,7 +22,7 @@ "Tacsipacsi" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentation]]\n* [[mw:Special:MyLanguage/API:FAQ|Häufig gestellte Fragen]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailingliste]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-Ankündigungen]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Fehlerberichte und Anfragen]\n
\nStatus: Alle auf dieser Seite gezeigten Funktionen sollten funktionieren, allerdings ist die API in aktiver Entwicklung und kann sich zu jeder Zeit ändern. Abonniere die [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ MediaWiki-API-Ankündigungs-Mailingliste], um über Aktualisierungen informiert zu werden.\n\nFehlerhafte Anfragen: Wenn fehlerhafte Anfragen an die API gesendet werden, wird ein HTTP-Header mit dem Schlüssel „MediaWiki-API-Error“ gesendet. Der Wert des Headers und der Fehlercode werden auf den gleichen Wert gesetzt. Für weitere Informationen siehe [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Fehler und Warnungen]].\n\n

Testen: Zum einfachen Testen von API-Anfragen, siehe [[Special:ApiSandbox]].

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentation]]\n* [[mw:Special:MyLanguage/API:FAQ|Häufig gestellte Fragen]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailingliste]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-Ankündigungen]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Fehlerberichte und Anfragen]\n
\nStatus: Die MediaWiki-API ist eine ausgereifte und stabile Schnittstelle, die aktiv unterstützt und verbessert wird. Während wir versuchen, dies zu vermeiden, können wir gelegentlich Breaking Changes erforderlich machen. Abonniere die [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ MediaWiki-API-Ankündigungs-Mailingliste] für Mitteilungen zu Aktualisierungen.\n\nFehlerhafte Anfragen: Wenn fehlerhafte Anfragen an die API gesendet werden, wird ein HTTP-Header mit dem Schlüssel „MediaWiki-API-Error“ gesendet. Der Wert des Headers und der Fehlercode werden auf den gleichen Wert gesetzt. Für weitere Informationen siehe [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Fehler und Warnungen]].\n\n

Testen: Zum einfachen Testen von API-Anfragen, siehe [[Special:ApiSandbox]].

", "apihelp-main-param-action": "Auszuführende Aktion.", "apihelp-main-param-format": "Format der Ausgabe.", "apihelp-main-param-maxlag": "maxlag kann verwendet werden, wenn MediaWiki auf einem datenbankreplizierten Cluster installiert ist. Um weitere Replikationsrückstände zu verhindern, lässt dieser Parameter den Client warten, bis der Replikationsrückstand kleiner als der angegebene Wert (in Sekunden) ist. Bei einem größerem Rückstand wird der Fehlercode maxlag zurückgegeben mit einer Nachricht wie Waiting for $host: $lag seconds lagged.
Siehe [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Handbuch: Maxlag parameter]] für weitere Informationen.", diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 8d7a61c9ec..3bbe399b45 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -7,7 +7,7 @@ }, "apihelp-main-summary": "", - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n
\nStatus: All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\nErroneous requests: When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Testing: For ease of testing API requests, see [[Special:ApiSandbox]].

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n
\nStatus: The MediaWiki API is a mature and stable interface that is actively supported and improved. While we try to avoid it, we may ocassionally need to make breaking changes; subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\nErroneous requests: When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Testing: For ease of testing API requests, see [[Special:ApiSandbox]].

", "apihelp-main-param-action": "Which action to perform.", "apihelp-main-param-format": "The format of the output.", "apihelp-main-param-maxlag": "Maximum lag can be used when MediaWiki is installed on a database replicated cluster. To save actions causing any more site replication lag, this parameter can make the client wait until the replication lag is less than the specified value. In case of excessive lag, error code maxlag is returned with a message like Waiting for $host: $lag seconds lagged.
See [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] for more information.", diff --git a/includes/api/i18n/fr.json b/includes/api/i18n/fr.json index 4c3b74d193..daa88d5102 100644 --- a/includes/api/i18n/fr.json +++ b/includes/api/i18n/fr.json @@ -33,7 +33,7 @@ "Kenjiraw" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n
\nÉtat : Toutes les fonctionnalités affichées sur cette page devraient fonctionner, mais l’API est encore en cours de développement et peut changer à tout moment. Inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\nRequêtes erronées : Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Test : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n
\nÉtat :L’API MédiaWiki est une interface stable et mature qui est supportée et améliorée de façon active. Bien que nous essayions de l’éviter, nous pouvons avoir parfois besoin de faire des modifications impactantes ; inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\nRequêtes erronées : Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Test : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].

", "apihelp-main-param-action": "Quelle action effectuer.", "apihelp-main-param-format": "Le format de sortie.", "apihelp-main-param-maxlag": "La latence maximale peut être utilisée quand MédiaWiki est installé sur un cluster de base de données répliqué. Pour éviter des actions provoquant un supplément de latence de réplication de site, ce paramètre peut faire attendre le client jusqu’à ce que la latence de réplication soit inférieure à une valeur spécifiée. En cas de latence excessive, le code d’erreur maxlag est renvoyé avec un message tel que Attente de $host : $lag secondes de délai.
Voyez [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manuel: Maxlag parameter]] pour plus d’information.", diff --git a/includes/api/i18n/it.json b/includes/api/i18n/it.json index 66b09428a1..38d290158f 100644 --- a/includes/api/i18n/it.json +++ b/includes/api/i18n/it.json @@ -19,7 +19,7 @@ "Margherita.mignanelli" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentazione]] (in inglese)\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]] (in inglese)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annunci sull'API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bug & richieste]\n
\nStato: tutte le funzioni e caratteristiche mostrate su questa pagina dovrebbero funzionare, ma le API sono ancora in fase attiva di sviluppo, e potrebbero cambiare in qualsiasi momento. Iscriviti alla [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la mailing list sugli annunci delle API MediaWiki] per essere informato sugli aggiornamenti.\n\nIstruzioni sbagliate: quando vengono impartite alle API delle istruzioni sbagliate, un'intestazione HTTP verrà inviata col messaggio \"MediaWiki-API-Error\" e, sia il valore dell'intestazione, sia il codice d'errore, verranno impostati con lo stesso valore. Per maggiori informazioni leggi [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Errori ed avvertimenti]] (in inglese).\n\n

Test: per testare facilmente le richieste API, vedi [[Special:ApiSandbox]].

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentazione]] (in inglese)\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]] (in inglese)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annunci sull'API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bug & richieste]\n
\nStato: l'API MediaWiki è un'interfaccia matura e stabile che è attivamente supportata e migliorata. Anche se cerchiamo di evitarlo, potremmo dover fare delle modifiche che causano malfunzionamenti; iscriviti alla [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mailing list sugli annunci delle API MediaWiki] per essere informato sugli aggiornamenti.\n\nIstruzioni sbagliate: quando vengono impartite alle API delle istruzioni sbagliate, un'intestazione HTTP verrà inviata col messaggio \"MediaWiki-API-Error\" e, sia il valore dell'intestazione, sia il codice d'errore, verranno impostati con lo stesso valore. Per maggiori informazioni leggi [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Errori ed avvertimenti]] (in inglese).\n\n

Test: per testare facilmente le richieste API, vedi [[Special:ApiSandbox]].

", "apihelp-main-param-action": "Azione da compiere.", "apihelp-main-param-format": "Formato dell'output.", "apihelp-main-param-assert": "Verifica che l'utente abbia effettuato l'accesso se si è impostato user, o che abbia i permessi di bot se si è impostato bot.", diff --git a/includes/api/i18n/ja.json b/includes/api/i18n/ja.json index f51b03fe0c..80f161be93 100644 --- a/includes/api/i18n/ja.json +++ b/includes/api/i18n/ja.json @@ -12,7 +12,8 @@ "Suchichi02", "Kkairri", "ネイ", - "Omotecho" + "Omotecho", + "Yusuke1109" ] }, "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|説明文書]]\n* [[mw:Special:MyLanguage/API:FAQ|よくある質問]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api メーリングリスト]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API 告知]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R バグの報告とリクエスト]\n
\n状態: このページに表示されている機能は全て動作するはずですが、この API は未だ活発に開発されており、変更される可能性があります。アップデートの通知を受け取るには、[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce メーリングリスト]に参加してください。\n\n誤ったリクエスト: 誤ったリクエストが API に送られた場合、\"MediaWiki-API-Error\" HTTP ヘッダーが送信され、そのヘッダーの値と送り返されるエラーコードは同じ値にセットされます。より詳しい情報は [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]] を参照してください。\n\n

テスト: API のリクエストのテストは、[[Special:ApiSandbox]]で簡単に行えます。

", @@ -35,7 +36,7 @@ "apihelp-block-param-autoblock": "その利用者が最後に使用したIPアドレスと、ブロック後に編集を試みた際のIPアドレスを自動的にブロックします。", "apihelp-block-param-noemail": "Wikiを通して電子メールを送信することを禁止します。(blockemail 権限が必要です)", "apihelp-block-param-hidename": "ブロック記録から利用者名を秘匿します。(hideuser 権限が必要です)", - "apihelp-block-param-allowusertalk": "自身のトークページの編集を許可する ([[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]] に依存)。", + "apihelp-block-param-allowusertalk": "自身のトークページの編集を許可する ([[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]] に依存)。", "apihelp-block-param-reblock": "その利用者がすでにブロックされている場合、ブロックを上書きします。", "apihelp-block-param-watchuser": "その利用者またはIPアドレスの利用者ページとトークページをウォッチします。", "apihelp-block-example-ip-simple": "IPアドレス 192.0.2.5 を First strike という理由で3日ブロックする", @@ -262,7 +263,7 @@ "apihelp-parse-paramvalue-prop-displaytitle": "構文解析されたウィキテキストのタイトルを追加します。", "apihelp-parse-paramvalue-prop-headitems": "ページの <head> の中に入れてアイテムを提供します。", "apihelp-parse-paramvalue-prop-headhtml": "ページの解析された <head> を与える。", - "apihelp-parse-paramvalue-prop-jsconfigvars": "ページに固有のJavaScriptの設定変数を提供します。", + "apihelp-parse-paramvalue-prop-jsconfigvars": "ページに固有のJavaScriptの設定変数を提供します。適用するには、mw.config.set()を使用します。", "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "JSON文字列としてページに固有のJavaScriptの設定変数を提供します。", "apihelp-parse-paramvalue-prop-indicators": "ページ上で使用されるページのステータスインジケータのHTMLを提供します。", "apihelp-parse-paramvalue-prop-iwlinks": "構文解析されたウィキテキスト内でウィキ間リンクを提供します。", diff --git a/includes/api/i18n/ko.json b/includes/api/i18n/ko.json index 034a03303c..1db9ed3b1f 100644 --- a/includes/api/i18n/ko.json +++ b/includes/api/i18n/ko.json @@ -192,6 +192,7 @@ "apihelp-login-example-login": "로그인.", "apihelp-logout-summary": "로그아웃하고 세션 데이터를 지웁니다.", "apihelp-logout-example-logout": "현재 사용자를 로그아웃합니다.", + "apihelp-managetags-summary": "변경 태그에 관한 관리 작업을 수행합니다.", "apihelp-mergehistory-summary": "문서 역사를 합칩니다.", "apihelp-mergehistory-param-reason": "문서 병합 이유.", "apihelp-move-summary": "문서 이동하기.", diff --git a/includes/api/i18n/lt.json b/includes/api/i18n/lt.json index 9d39405b06..6f2a72c84b 100644 --- a/includes/api/i18n/lt.json +++ b/includes/api/i18n/lt.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Zygimantus", - "Eitvys200" + "Eitvys200", + "Hugo.arg" ] }, "apihelp-main-param-action": "Kurį veiksmą atlikti.", @@ -78,7 +79,7 @@ "apihelp-feedwatchlist-param-feedformat": "Srauto formatas.", "apihelp-feedwatchlist-example-default": "Rodyti stebimųjų sąrašo srautą.", "apihelp-feedwatchlist-example-all6hrs": "Rodyti visus pakeitimus stebimuose puslapiuose per paskutines 6 valandas.", - "apihelp-filerevert-param-comment": "Įkėlimo komentaras.", + "apihelp-filerevert-param-comment": "Įkėlimo pastabos.", "apihelp-help-summary": "Rodyti pagalbą pasirinktiems moduliams.", "apihelp-help-example-main": "Pagalba pagrindiniam moduliui.", "apihelp-help-example-recursive": "Visa pagalba viename puslapyje.", diff --git a/includes/api/i18n/pt-br.json b/includes/api/i18n/pt-br.json index fec224e38f..a2a0e79051 100644 --- a/includes/api/i18n/pt-br.json +++ b/includes/api/i18n/pt-br.json @@ -17,7 +17,7 @@ "Hamilton Abreu" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discussão]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Erros e pedidos]\n
\nEstado: Todas as funcionalidades mostradas nesta página devem ter o comportamento documentado, mas a API ainda está em desenvolvimento ativo e pode ser alterada a qualquer momento. Inscreva-se na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discussão mediawiki-api-announce] para ser informado acerca das atualizações.\n\nPedidos incorretos: Quando são enviados pedidos incorretos à API, será devolvido um cabeçalho HTTP com a chave \"MediaWiki-API-Error\" e depois tanto o valor desse cabeçalho como o código de erro devolvido serão definidos com o mesmo valor. Para mais informação, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Erros e avisos]].\n\n

\nTestes: Para testar facilmente pedidos à API, visite [[Special:ApiSandbox|Testes da API]].\n

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discussão]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Erros e pedidos]\n
\nEstado: A API MediaWiki é uma interface madura e estável que é ativamente suportada e aprimorada. Enquanto tentamos evitá-lo, talvez ocortamente precisemos fazer mudanças de ruptura; se inscrever [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discussão mediawiki-api-announce] para ser informado acerca das atualizações.\n\nPedidos incorretos: Quando são enviados pedidos incorretos à API, será devolvido um cabeçalho HTTP com a chave \"MediaWiki-API-Error\" e depois tanto o valor desse cabeçalho como o código de erro devolvido serão definidos com o mesmo valor. Para mais informação, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Erros e avisos]].\n\n

\nTestes: Para testar facilmente pedidos à API, visite [[Special:ApiSandbox|Testes da API]].\n

", "apihelp-main-param-action": "Qual ação executar.", "apihelp-main-param-format": "O formato da saída.", "apihelp-main-param-maxlag": "O atraso máximo pode ser usado quando o MediaWiki está instalado em um cluster replicado no banco de dados. Para salvar as ações que causam mais atraso na replicação do site, esse parâmetro pode fazer o cliente aguardar até que o atraso da replicação seja menor do que o valor especificado. Em caso de atraso excessivo, o código de erro maxlag é retornado com uma mensagem como Waiting for $host: $lag seconds lagged.
Veja [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] para mais informações.", diff --git a/includes/api/i18n/pt.json b/includes/api/i18n/pt.json index c18160a521..14cf5aa021 100644 --- a/includes/api/i18n/pt.json +++ b/includes/api/i18n/pt.json @@ -12,7 +12,7 @@ "Waldir" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discussão]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Erros e pedidos]\n
\nEstado: Todas as funcionalidades mostradas nesta página devem ter o comportamento documentado, mas a API ainda está em desenvolvimento ativo e pode ser alterada a qualquer momento. Inscreva-se na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discussão mediawiki-api-announce] para ser informado acerca das atualizações.\n\nPedidos incorretos: Quando são enviados pedidos incorretos à API, será devolvido um cabeçalho HTTP com a chave \"MediaWiki-API-Error\" e depois tanto o valor desse cabeçalho como o código de erro devolvido serão definidos com o mesmo valor. Para mais informação, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Erros e avisos]].\n\n

\nTestes: Para testar facilmente pedidos à API, visite [[Special:ApiSandbox|Testes da API]].\n

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de divulgação]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Defeitos e pedidos]\n
\nEstado: A API do MediaWiki é uma interface consolidada e estável que é constantemente suportada e melhorada. Embora tentemos evitá-lo, podemos ocasionalmente realizar alterações disruptivas. Inscreva-se na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de distribuição mediawiki-api-announce] para receber notificações das atualizações.\n\nPedidos incorretos: Quando são enviados pedidos incorretos à API, será devolvido um cabeçalho HTTP com a chave \"MediaWiki-API-Error\" e depois tanto o valor desse cabeçalho como o código de erro devolvido serão definidos com o mesmo valor. Para mais informação, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Erros e avisos]].\n\n

Testes: Para testar facilmente pedidos à API, visite [[Special:ApiSandbox|Testes da API]].

", "apihelp-main-param-action": "A operação a ser realizada.", "apihelp-main-param-format": "O formato do resultado.", "apihelp-main-param-maxlag": "O atraso máximo pode ser usado quando o MediaWiki é instalado num ''cluster'' de bases de dados replicadas. Para impedir que as operações causem ainda mais atrasos de replicação do sítio, este parâmetro pode fazer o cliente aguardar até que o atraso de replicação seja inferior ao valor especificado. Caso o atraso atual exceda esse valor, o código de erro maxlag é devolvido com uma mensagem como À espera do servidor $host: $lag segundos de atraso.
Consulte [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Parâmetro maxlag]] para mais informações.", @@ -364,7 +364,7 @@ "apihelp-parse-param-disablepp": "Em vez deste, usar $1disablelimitreport.", "apihelp-parse-param-disableeditsection": "Omitir as hiperligações para edição da secção no resultado da análise sintática.", "apihelp-parse-param-disabletidy": "Não fazer a limpeza do HTML (isto é, o ''tidy'') no resultado da análise sintática.", - "apihelp-parse-param-disablestylededuplication": "Não desduplica as folhas de estilo incluídas na saída do analisador sintático.", + "apihelp-parse-param-disablestylededuplication": "Não desduplicar as folhas de estilo internas (etiquetas - + +
!! html/php+tidy
@@ -29917,6 +29611,86 @@ wgRawHtml=1
!! end +!! test +Validating that + + + +But if it's on a line with other content, let it be wrapped. + + bar + +foo + +foo bar + +And the same if we have non-paragraph-breaking whitespace + +foo + +bar +!! html/php +

A style tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph +

+ + +

But if it's on a line with other content, let it be wrapped. +

bar +

foo +

foo bar +

And the same if we have non-paragraph-breaking whitespace +

foo + +bar +

+!! end + +!! test +Validating that isn't wrapped in a paragraph (T186965) +!! options +styletag=1 +!! wikitext +A link tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph + + + + + +But if it's on a line with other content, let it be wrapped. + + bar + +foo + +foo bar + +And the same if we have non-paragraph-breaking whitespace + +foo + +bar +!! html/php +

A link tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph +

+ + +

But if it's on a line with other content, let it be wrapped. +

bar +

foo +

foo bar +

And the same if we have non-paragraph-breaking whitespace +

foo + +bar +

+!! end + !! test Decoding of HTML entities in headings and links for IDs and link fragments (T103714) !! config @@ -30172,8 +29946,8 @@ T51672: Test for brackets in attributes of elements in external link texts link span

!! html/parsoid -

link span -link span

+

link span +link span

!! end !! test diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index 652b1ee1bf..92c0714eef 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -1435,8 +1435,9 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase { */ private function resetDB( $db, $tablesUsed ) { if ( $db ) { - $userTables = [ 'user', 'user_groups', 'user_properties' ]; - $pageTables = [ 'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment' ]; + $userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ]; + $pageTables = [ 'page', 'revision', 'ip_changes', 'revision_comment_temp', + 'revision_actor_temp', 'comment' ]; $coreDBDataTables = array_merge( $userTables, $pageTables ); // If any of the user or page tables were marked as used, we should clear all of them. diff --git a/tests/phpunit/data/media/jpeg-segment-loop1.jpg b/tests/phpunit/data/media/jpeg-segment-loop1.jpg new file mode 100644 index 0000000000..962f3fe0e7 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-segment-loop1.jpg differ diff --git a/tests/phpunit/data/media/jpeg-segment-loop2.jpg b/tests/phpunit/data/media/jpeg-segment-loop2.jpg new file mode 100644 index 0000000000..e3a7505c00 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-segment-loop2.jpg differ diff --git a/tests/phpunit/includes/ActorMigrationTest.php b/tests/phpunit/includes/ActorMigrationTest.php new file mode 100644 index 0000000000..1b0c848bb6 --- /dev/null +++ b/tests/phpunit/includes/ActorMigrationTest.php @@ -0,0 +1,695 @@ +makeMigration( $stage ); + $result = $m->getJoin( $key ); + $this->assertEquals( $expect, $result ); + } + + public static function provideGetJoin() { + return [ + 'Simple table, old' => [ + MIGRATION_OLD, 'rc_user', [ + 'tables' => [], + 'fields' => [ + 'rc_user' => 'rc_user', + 'rc_user_text' => 'rc_user_text', + 'rc_actor' => 'NULL', + ], + 'joins' => [], + ], + ], + 'Simple table, write-both' => [ + MIGRATION_WRITE_BOTH, 'rc_user', [ + 'tables' => [ 'actor_rc_user' => 'actor' ], + 'fields' => [ + 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )', + 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )', + 'rc_actor' => 'rc_actor', + ], + 'joins' => [ + 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ], + ], + ], + ], + 'Simple table, write-new' => [ + MIGRATION_WRITE_NEW, 'rc_user', [ + 'tables' => [ 'actor_rc_user' => 'actor' ], + 'fields' => [ + 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )', + 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )', + 'rc_actor' => 'rc_actor', + ], + 'joins' => [ + 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ], + ], + ], + ], + 'Simple table, new' => [ + MIGRATION_NEW, 'rc_user', [ + 'tables' => [ 'actor_rc_user' => 'actor' ], + 'fields' => [ + 'rc_user' => 'actor_rc_user.actor_user', + 'rc_user_text' => 'actor_rc_user.actor_name', + 'rc_actor' => 'rc_actor', + ], + 'joins' => [ + 'actor_rc_user' => [ 'JOIN', 'actor_rc_user.actor_id = rc_actor' ], + ], + ], + ], + + 'ipblocks, old' => [ + MIGRATION_OLD, 'ipb_by', [ + 'tables' => [], + 'fields' => [ + 'ipb_by' => 'ipb_by', + 'ipb_by_text' => 'ipb_by_text', + 'ipb_by_actor' => 'NULL', + ], + 'joins' => [], + ], + ], + 'ipblocks, write-both' => [ + MIGRATION_WRITE_BOTH, 'ipb_by', [ + 'tables' => [ 'actor_ipb_by' => 'actor' ], + 'fields' => [ + 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )', + 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )', + 'ipb_by_actor' => 'ipb_by_actor', + ], + 'joins' => [ + 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ], + ], + ], + ], + 'ipblocks, write-new' => [ + MIGRATION_WRITE_NEW, 'ipb_by', [ + 'tables' => [ 'actor_ipb_by' => 'actor' ], + 'fields' => [ + 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )', + 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )', + 'ipb_by_actor' => 'ipb_by_actor', + ], + 'joins' => [ + 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ], + ], + ], + ], + 'ipblocks, new' => [ + MIGRATION_NEW, 'ipb_by', [ + 'tables' => [ 'actor_ipb_by' => 'actor' ], + 'fields' => [ + 'ipb_by' => 'actor_ipb_by.actor_user', + 'ipb_by_text' => 'actor_ipb_by.actor_name', + 'ipb_by_actor' => 'ipb_by_actor', + ], + 'joins' => [ + 'actor_ipb_by' => [ 'JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ], + ], + ], + ], + + 'Revision, old' => [ + MIGRATION_OLD, 'rev_user', [ + 'tables' => [], + 'fields' => [ + 'rev_user' => 'rev_user', + 'rev_user_text' => 'rev_user_text', + 'rev_actor' => 'NULL', + ], + 'joins' => [], + ], + ], + 'Revision, write-both' => [ + MIGRATION_WRITE_BOTH, 'rev_user', [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + 'actor_rev_user' => 'actor', + ], + 'fields' => [ + 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )', + 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )', + 'rev_actor' => 'temp_rev_user.revactor_actor', + ], + 'joins' => [ + 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ], + ], + ], + ], + 'Revision, write-new' => [ + MIGRATION_WRITE_NEW, 'rev_user', [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + 'actor_rev_user' => 'actor', + ], + 'fields' => [ + 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )', + 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )', + 'rev_actor' => 'temp_rev_user.revactor_actor', + ], + 'joins' => [ + 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ], + ], + ], + ], + 'Revision, new' => [ + MIGRATION_NEW, 'rev_user', [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + 'actor_rev_user' => 'actor', + ], + 'fields' => [ + 'rev_user' => 'actor_rev_user.actor_user', + 'rev_user_text' => 'actor_rev_user.actor_name', + 'rev_actor' => 'temp_rev_user.revactor_actor', + ], + 'joins' => [ + 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + 'actor_rev_user' => [ 'JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ], + ], + ], + ], + ]; + } + + /** + * @dataProvider provideGetWhere + * @param int $stage + * @param string $key + * @param UserIdentity[] $users + * @param bool $useId + * @param array $expect + */ + public function testGetWhere( $stage, $key, $users, $useId, $expect ) { + $expect['conds'] = '(' . implode( ') OR (', $expect['orconds'] ) . ')'; + + if ( count( $users ) === 1 ) { + $users = reset( $users ); + } + + $m = $this->makeMigration( $stage ); + $result = $m->getWhere( $this->db, $key, $users, $useId ); + $this->assertEquals( $expect, $result ); + } + + public function provideGetWhere() { + $makeUserIdentity = function ( $id, $name, $actor ) { + $u = $this->getMock( UserIdentity::class ); + $u->method( 'getId' )->willReturn( $id ); + $u->method( 'getName' )->willReturn( $name ); + $u->method( 'getActorId' )->willReturn( $actor ); + return $u; + }; + + $genericUser = [ $makeUserIdentity( 1, 'User1', 11 ) ]; + $complicatedUsers = [ + $makeUserIdentity( 1, 'User1', 11 ), + $makeUserIdentity( 2, 'User2', 12 ), + $makeUserIdentity( 3, 'User3', 0 ), + $makeUserIdentity( 0, '192.168.12.34', 34 ), + $makeUserIdentity( 0, '192.168.12.35', 0 ), + ]; + + return [ + 'Simple table, old' => [ + MIGRATION_OLD, 'rc_user', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ 'userid' => "rc_user = '1'" ], + 'joins' => [], + ], + ], + 'Simple table, write-both' => [ + MIGRATION_WRITE_BOTH, 'rc_user', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor = '11'", + 'userid' => "rc_actor = '0' AND rc_user = '1'" + ], + 'joins' => [], + ], + ], + 'Simple table, write-new' => [ + MIGRATION_WRITE_NEW, 'rc_user', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor = '11'", + 'userid' => "rc_actor = '0' AND rc_user = '1'" + ], + 'joins' => [], + ], + ], + 'Simple table, new' => [ + MIGRATION_NEW, 'rc_user', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ 'actor' => "rc_actor = '11'" ], + 'joins' => [], + ], + ], + + 'ipblocks, old' => [ + MIGRATION_OLD, 'ipb_by', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ 'userid' => "ipb_by = '1'" ], + 'joins' => [], + ], + ], + 'ipblocks, write-both' => [ + MIGRATION_WRITE_BOTH, 'ipb_by', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "ipb_by_actor = '11'", + 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'" + ], + 'joins' => [], + ], + ], + 'ipblocks, write-new' => [ + MIGRATION_WRITE_NEW, 'ipb_by', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "ipb_by_actor = '11'", + 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'" + ], + 'joins' => [], + ], + ], + 'ipblocks, new' => [ + MIGRATION_NEW, 'ipb_by', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ 'actor' => "ipb_by_actor = '11'" ], + 'joins' => [], + ], + ], + + 'Revision, old' => [ + MIGRATION_OLD, 'rev_user', $genericUser, true, [ + 'tables' => [], + 'orconds' => [ 'userid' => "rev_user = '1'" ], + 'joins' => [], + ], + ], + 'Revision, write-both' => [ + MIGRATION_WRITE_BOTH, 'rev_user', $genericUser, true, [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + ], + 'orconds' => [ + 'actor' => + "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'", + 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'" + ], + 'joins' => [ + 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + ], + ], + ], + 'Revision, write-new' => [ + MIGRATION_WRITE_NEW, 'rev_user', $genericUser, true, [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + ], + 'orconds' => [ + 'actor' => + "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'", + 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'" + ], + 'joins' => [ + 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + ], + ], + ], + 'Revision, new' => [ + MIGRATION_NEW, 'rev_user', $genericUser, true, [ + 'tables' => [ + 'temp_rev_user' => 'revision_actor_temp', + ], + 'orconds' => [ 'actor' => "temp_rev_user.revactor_actor = '11'" ], + 'joins' => [ + 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ], + ], + ], + ], + + 'Multiple users, old' => [ + MIGRATION_OLD, 'rc_user', $complicatedUsers, true, [ + 'tables' => [], + 'orconds' => [ + 'userid' => "rc_user IN ('1','2','3') ", + 'username' => "rc_user_text IN ('192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, write-both' => [ + MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor IN ('11','12','34') ", + 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ", + 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, write-new' => [ + MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, true, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor IN ('11','12','34') ", + 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ", + 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, new' => [ + MIGRATION_NEW, 'rc_user', $complicatedUsers, true, [ + 'tables' => [], + 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ], + 'joins' => [], + ], + ], + + 'Multiple users, no use ID, old' => [ + MIGRATION_OLD, 'rc_user', $complicatedUsers, false, [ + 'tables' => [], + 'orconds' => [ + 'username' => "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, write-both' => [ + MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, false, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor IN ('11','12','34') ", + 'username' => "rc_actor = '0' AND " + . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, write-new' => [ + MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, false, [ + 'tables' => [], + 'orconds' => [ + 'actor' => "rc_actor IN ('11','12','34') ", + 'username' => "rc_actor = '0' AND " + . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') " + ], + 'joins' => [], + ], + ], + 'Multiple users, new' => [ + MIGRATION_NEW, 'rc_user', $complicatedUsers, false, [ + 'tables' => [], + 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ], + 'joins' => [], + ], + ], + ]; + } + + /** + * @dataProvider provideInsertRoundTrip + * @param string $table + * @param string $key + * @param string $pk + * @param array $extraFields + */ + public function testInsertRoundTrip( $table, $key, $pk, $extraFields ) { + $u = $this->getTestUser()->getUser(); + $user = $this->getMock( UserIdentity::class ); + $user->method( 'getId' )->willReturn( $u->getId() ); + $user->method( 'getName' )->willReturn( $u->getName() ); + if ( $u->getActorId( $this->db ) ) { + $user->method( 'getActorId' )->willReturn( $u->getActorId() ); + } else { + $this->db->insert( + 'actor', + [ 'actor_user' => $u->getId(), 'actor_name' => $u->getName() ], + __METHOD__ + ); + $user->method( 'getActorId' )->willReturn( $this->db->insertId() ); + } + + $stages = [ + MIGRATION_OLD => [ MIGRATION_OLD, MIGRATION_WRITE_NEW ], + MIGRATION_WRITE_BOTH => [ MIGRATION_OLD, MIGRATION_NEW ], + MIGRATION_WRITE_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ], + MIGRATION_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ], + ]; + + $nameKey = $key . '_text'; + $actorKey = $key === 'ipb_by' ? 'ipb_by_actor' : substr( $key, 0, -5 ) . '_actor'; + + foreach ( $stages as $writeStage => $readRange ) { + if ( $key === 'ipb_by' ) { + $extraFields['ipb_address'] = __CLASS__ . "#$writeStage"; + } + + $w = $this->makeMigration( $writeStage ); + $usesTemp = $key === 'rev_user'; + + if ( $usesTemp ) { + list( $fields, $callback ) = $w->getInsertValuesWithTempTable( $this->db, $key, $user ); + } else { + $fields = $w->getInsertValues( $this->db, $key, $user ); + } + + if ( $writeStage <= MIGRATION_WRITE_BOTH ) { + $this->assertSame( $user->getId(), $fields[$key], "old field, stage=$writeStage" ); + $this->assertSame( $user->getName(), $fields[$nameKey], "old field, stage=$writeStage" ); + } else { + $this->assertArrayNotHasKey( $key, $fields, "old field, stage=$writeStage" ); + $this->assertArrayNotHasKey( $nameKey, $fields, "old field, stage=$writeStage" ); + } + if ( $writeStage >= MIGRATION_WRITE_BOTH && !$usesTemp ) { + $this->assertSame( $user->getActorId(), $fields[$actorKey], "new field, stage=$writeStage" ); + } else { + $this->assertArrayNotHasKey( $actorKey, $fields, "new field, stage=$writeStage" ); + } + + $this->db->insert( $table, $extraFields + $fields, __METHOD__ ); + $id = $this->db->insertId(); + if ( $usesTemp ) { + $callback( $id, $extraFields ); + } + + for ( $readStage = $readRange[0]; $readStage <= $readRange[1]; $readStage++ ) { + $r = $this->makeMigration( $readStage ); + + $queryInfo = $r->getJoin( $key ); + $row = $this->db->selectRow( + [ $table ] + $queryInfo['tables'], + $queryInfo['fields'], + [ $pk => $id ], + __METHOD__, + [], + $queryInfo['joins'] + ); + + $this->assertSame( $user->getId(), (int)$row->$key, "w=$writeStage, r=$readStage, id" ); + $this->assertSame( $user->getName(), $row->$nameKey, "w=$writeStage, r=$readStage, name" ); + $this->assertSame( + $readStage === MIGRATION_OLD || $writeStage === MIGRATION_OLD ? 0 : $user->getActorId(), + (int)$row->$actorKey, + "w=$writeStage, r=$readStage, actor" + ); + } + } + } + + public static function provideInsertRoundTrip() { + $db = wfGetDB( DB_REPLICA ); // for timestamps + + $ipbfields = [ + ]; + $revfields = [ + ]; + + return [ + 'recentchanges' => [ 'recentchanges', 'rc_user', 'rc_id', [ + 'rc_timestamp' => $db->timestamp(), + 'rc_namespace' => 0, + 'rc_title' => 'Test', + 'rc_this_oldid' => 42, + 'rc_last_oldid' => 41, + 'rc_source' => 'test', + ] ], + 'ipblocks' => [ 'ipblocks', 'ipb_by', 'ipb_id', [ + 'ipb_range_start' => '', + 'ipb_range_end' => '', + 'ipb_timestamp' => $db->timestamp(), + 'ipb_expiry' => $db->getInfinity(), + ] ], + 'revision' => [ 'revision', 'rev_user', 'rev_id', [ + 'rev_page' => 42, + 'rev_text_id' => 42, + 'rev_len' => 0, + 'rev_timestamp' => $db->timestamp(), + ] ], + ]; + } + + public static function provideStages() { + return [ + 'MIGRATION_OLD' => [ MIGRATION_OLD ], + 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH ], + 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW ], + 'MIGRATION_NEW' => [ MIGRATION_NEW ], + ]; + } + + /** + * @dataProvider provideStages + * @param int $stage + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Must use getInsertValuesWithTempTable() for rev_user + */ + public function testInsertWrong( $stage ) { + $m = $this->makeMigration( $stage ); + $m->getInsertValues( $this->db, 'rev_user', $this->getTestUser()->getUser() ); + } + + /** + * @dataProvider provideStages + * @param int $stage + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Must use getInsertValues() for rc_user + */ + public function testInsertWithTempTableWrong( $stage ) { + $m = $this->makeMigration( $stage ); + $m->getInsertValuesWithTempTable( $this->db, 'rc_user', $this->getTestUser()->getUser() ); + } + + /** + * @dataProvider provideStages + * @param int $stage + */ + public function testInsertWithTempTableDeprecated( $stage ) { + $wrap = TestingAccessWrapper::newFromClass( ActorMigration::class ); + $wrap->formerTempTables += [ 'rc_user' => '1.30' ]; + + $this->hideDeprecated( 'ActorMigration::getInsertValuesWithTempTable for rc_user' ); + $m = $this->makeMigration( $stage ); + list( $fields, $callback ) + = $m->getInsertValuesWithTempTable( $this->db, 'rc_user', $this->getTestUser()->getUser() ); + $this->assertTrue( is_callable( $callback ) ); + } + + /** + * @dataProvider provideStages + * @param int $stage + * @expectedException InvalidArgumentException + * @expectedExceptionMessage $extra[rev_timestamp] is not provided + */ + public function testInsertWithTempTableCallbackMissingFields( $stage ) { + $m = $this->makeMigration( $stage ); + list( $fields, $callback ) + = $m->getInsertValuesWithTempTable( $this->db, 'rev_user', $this->getTestUser()->getUser() ); + $callback( 1, [] ); + } + + public function testInsertUserIdentity() { + $user = $this->getTestUser()->getUser(); + $userIdentity = $this->getMock( UserIdentity::class ); + $userIdentity->method( 'getId' )->willReturn( $user->getId() ); + $userIdentity->method( 'getName' )->willReturn( $user->getName() ); + $userIdentity->method( 'getActorId' )->willReturn( 0 ); + + list( $cFields, $cCallback ) = CommentStore::newKey( 'rev_comment' ) + ->insertWithTempTable( $this->db, '' ); + $m = $this->makeMigration( MIGRATION_WRITE_BOTH ); + list( $fields, $callback ) = + $m->getInsertValuesWithTempTable( $this->db, 'rev_user', $userIdentity ); + $extraFields = [ + 'rev_page' => 42, + 'rev_text_id' => 42, + 'rev_len' => 0, + 'rev_timestamp' => $this->db->timestamp(), + ] + $cFields; + $this->db->insert( 'revision', $extraFields + $fields, __METHOD__ ); + $id = $this->db->insertId(); + $callback( $id, $extraFields ); + $cCallback( $id ); + + $qi = Revision::getQueryInfo(); + $row = $this->db->selectRow( + $qi['tables'], $qi['fields'], [ 'rev_id' => $id ], __METHOD__, [], $qi['joins'] + ); + $this->assertSame( $user->getId(), (int)$row->rev_user ); + $this->assertSame( $user->getName(), $row->rev_user_text ); + $this->assertSame( $user->getActorId(), (int)$row->rev_actor ); + + $m = $this->makeMigration( MIGRATION_WRITE_BOTH ); + $fields = $m->getInsertValues( $this->db, 'dummy_user', $userIdentity ); + $this->assertSame( $user->getId(), $fields['dummy_user'] ); + $this->assertSame( $user->getName(), $fields['dummy_user_text'] ); + $this->assertSame( $user->getActorId(), $fields['dummy_actor'] ); + } + + public function testConstructor() { + $m = ActorMigration::newMigration(); + $this->assertInstanceOf( ActorMigration::class, $m ); + $this->assertSame( $m, ActorMigration::newMigration() ); + } + + /** + * @dataProvider provideIsAnon + * @param int $stage + * @param string $isAnon + * @param string $isNotAnon + */ + public function testIsAnon( $stage, $isAnon, $isNotAnon ) { + $m = $this->makeMigration( $stage ); + $this->assertSame( $isAnon, $m->isAnon( 'foo' ) ); + $this->assertSame( $isNotAnon, $m->isNotAnon( 'foo' ) ); + } + + public static function provideIsAnon() { + return [ + 'MIGRATION_OLD' => [ MIGRATION_OLD, 'foo = 0', 'foo != 0' ], + 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH, 'foo = 0', 'foo != 0' ], + 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW, 'foo = 0', 'foo != 0' ], + 'MIGRATION_NEW' => [ MIGRATION_NEW, 'foo IS NULL', 'foo IS NOT NULL' ], + ]; + } + +} diff --git a/tests/phpunit/includes/BlockTest.php b/tests/phpunit/includes/BlockTest.php index 1e46555cbd..19780a684a 100644 --- a/tests/phpunit/includes/BlockTest.php +++ b/tests/phpunit/includes/BlockTest.php @@ -35,6 +35,7 @@ class BlockTest extends MediaWikiLangTestCase { $blockOptions = [ 'address' => $user->getName(), 'user' => $user->getId(), + 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => 'Parce que', 'expiry' => time() + 100500, ]; @@ -393,7 +394,7 @@ class BlockTest extends MediaWikiLangTestCase { $block = new Block( /* address */ $username, /* user */ 0, - /* by */ 0, + /* by */ $this->getTestSysop()->getUser()->getId(), /* reason */ $reason, /* timestamp */ 0, /* auto */ false, diff --git a/tests/phpunit/includes/CommentStoreTest.php b/tests/phpunit/includes/CommentStoreTest.php index e332cddf63..a51089763a 100644 --- a/tests/phpunit/includes/CommentStoreTest.php +++ b/tests/phpunit/includes/CommentStoreTest.php @@ -542,7 +542,6 @@ class CommentStoreTest extends MediaWikiLangTestCase { $ipbfields = [ 'ipb_range_start' => '', 'ipb_range_end' => '', - 'ipb_by' => 0, 'ipb_timestamp' => $db->timestamp(), 'ipb_expiry' => $db->getInfinity(), ]; @@ -550,8 +549,6 @@ class CommentStoreTest extends MediaWikiLangTestCase { 'rev_page' => 42, 'rev_text_id' => 42, 'rev_len' => 0, - 'rev_user' => 0, - 'rev_user_text' => '', 'rev_timestamp' => $db->timestamp(), ]; $comStoreComment = new CommentStoreComment( diff --git a/tests/phpunit/includes/PageArchiveTest.php b/tests/phpunit/includes/PageArchiveTest.php index 6fbe053361..7fdc3ed8e1 100644 --- a/tests/phpunit/includes/PageArchiveTest.php +++ b/tests/phpunit/includes/PageArchiveTest.php @@ -50,6 +50,10 @@ class PageArchiveTest extends MediaWikiTestCase { protected function setUp() { parent::setUp(); + $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); + $this->overrideMwServices(); + // First create our dummy page $page = Title::newFromText( 'PageArchiveTest_thePage' ); $page = new WikiPage( $page ); @@ -84,28 +88,44 @@ class PageArchiveTest extends MediaWikiTestCase { public function testUndeleteRevisions() { // First make sure old revisions are archived $dbr = wfGetDB( DB_REPLICA ); - $res = $dbr->select( 'archive', '*', [ 'ar_rev_id' => $this->ipRevId ] ); + $arQuery = Revision::getArchiveQueryInfo(); + $res = $dbr->select( + $arQuery['tables'], + $arQuery['fields'], + [ 'ar_rev_id' => $this->ipRevId ], + __METHOD__, + [], + $arQuery['joins'] + ); $row = $res->fetchObject(); $this->assertEquals( $this->ipEditor, $row->ar_user_text ); // Should not be in revision - $res = $dbr->select( 'revision', '*', [ 'rev_id' => $this->ipRevId ] ); + $res = $dbr->select( 'revision', '1', [ 'rev_id' => $this->ipRevId ] ); $this->assertFalse( $res->fetchObject() ); // Should not be in ip_changes - $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $this->ipRevId ] ); + $res = $dbr->select( 'ip_changes', '1', [ 'ipc_rev_id' => $this->ipRevId ] ); $this->assertFalse( $res->fetchObject() ); // Restore the page $this->archivedPage->undelete( [] ); // Should be back in revision - $res = $dbr->select( 'revision', '*', [ 'rev_id' => $this->ipRevId ] ); + $revQuery = Revision::getQueryInfo(); + $res = $dbr->select( + $revQuery['tables'], + $revQuery['fields'], + [ 'rev_id' => $this->ipRevId ], + __METHOD__, + [], + $revQuery['joins'] + ); $row = $res->fetchObject(); $this->assertEquals( $this->ipEditor, $row->rev_user_text ); // Should be back in ip_changes - $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $this->ipRevId ] ); + $res = $dbr->select( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->ipRevId ] ); $row = $res->fetchObject(); $this->assertEquals( IP::toHex( $this->ipEditor ), $row->ipc_hex ); } @@ -134,6 +154,7 @@ class PageArchiveTest extends MediaWikiTestCase { 'ar_minor_edit' => '0', 'ar_user' => '0', 'ar_user_text' => '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7', + 'ar_actor' => null, 'ar_len' => '11', 'ar_deleted' => '0', 'ar_rev_id' => '3', @@ -159,6 +180,7 @@ class PageArchiveTest extends MediaWikiTestCase { 'ar_minor_edit' => '0', 'ar_user' => '0', 'ar_user_text' => '127.0.0.1', + 'ar_actor' => null, 'ar_len' => '7', 'ar_deleted' => '0', 'ar_rev_id' => '2', diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index b05a742d20..61f18021de 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -108,7 +108,9 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { } if ( !isset( $props['user_text'] ) ) { - $props['user_text'] = 'Tester'; + $user = $this->getTestUser()->getUser(); + $props['user_text'] = $user->getName(); + $props['user'] = $user->getId(); } if ( !isset( $props['user'] ) ) { @@ -243,7 +245,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { 'rev_id', 'rev_page', 'rev_text_id', - 'rev_user', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -257,7 +258,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { strval( $textId ), '0', '0', - '0', '13', strval( $parentId ), 's0ngbdoxagreuf2vjtuxzwdz64n29xm', @@ -397,7 +397,8 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $services->getDBLoadBalancer(), $services->getService( '_SqlBlobStore' ), $services->getMainWANObjectCache(), - $services->getCommentStore() + $services->getCommentStore(), + $services->getActorMigration() ); $store->setContentHandlerUseDB( $this->getContentHandlerUseDB() ); @@ -745,15 +746,17 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { // test it --------------------------------- $since = $revisions[$sinceIdx]->getTimestamp(); + $revQuery = Revision::getQueryInfo(); $allRows = iterator_to_array( $dbw->select( - 'revision', - [ 'rev_id', 'rev_timestamp', 'rev_user' ], + $revQuery['tables'], + [ 'rev_id', 'rev_timestamp', 'rev_user' => $revQuery['fields']['rev_user'] ], [ 'rev_page' => $page->getId(), //'rev_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $since ) ) ], __METHOD__, - [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] + [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ], + $revQuery['joins'] ) ); $wasLast = Revision::userWasLastToEdit( $dbw, $page->getId(), $userA->getId(), $since ); diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php index 8eac064a2a..8b644c57cb 100644 --- a/tests/phpunit/includes/RevisionTest.php +++ b/tests/phpunit/includes/RevisionTest.php @@ -157,13 +157,6 @@ class RevisionTest extends MediaWikiTestCase { 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.' ) @@ -494,7 +487,8 @@ class RevisionTest extends MediaWikiTestCase { $lb, $this->getBlobStore(), $cache, - MediaWikiServices::getInstance()->getCommentStore() + MediaWikiServices::getInstance()->getCommentStore(), + MediaWikiServices::getInstance()->getActorMigration() ); return $blobStore; } @@ -617,6 +611,7 @@ class RevisionTest extends MediaWikiTestCase { */ public function testLoadFromTitle() { $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); $this->overrideMwServices(); $title = $this->getMockTitle(); @@ -875,6 +870,8 @@ class RevisionTest extends MediaWikiTestCase { */ public function testUserJoinCond() { $this->hideDeprecated( 'Revision::userJoinCond' ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); + $this->overrideMwServices(); $this->assertEquals( [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ], Revision::userJoinCond() @@ -892,7 +889,7 @@ class RevisionTest extends MediaWikiTestCase { ); } - private function overrideCommentStore() { + private function overrideCommentStoreAndActorMigration() { $mockStore = $this->getMockBuilder( CommentStore::class ) ->disableOriginalConstructor() ->getMock(); @@ -906,8 +903,26 @@ class RevisionTest extends MediaWikiTestCase { 'fields' => [ 'commentstore' => 'field' ], 'joins' => [ 'commentstore' => 'join' ], ] ); - $this->setService( 'CommentStore', $mockStore ); + + $mockStore = $this->getMockBuilder( ActorMigration::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockStore->expects( $this->any() ) + ->method( 'getJoin' ) + ->willReturnCallback( function ( $key ) { + $p = strtok( $key, '_' ); + return [ + 'tables' => [ 'actormigration' => 'table' ], + 'fields' => [ + $p . '_user' => 'actormigration_user', + $p . '_user_text' => 'actormigration_user_text', + $p . '_actor' => 'actormigration_actor', + ], + 'joins' => [ 'actormigration' => 'join' ], + ]; + } ); + $this->setService( 'ActorMigration', $mockStore ); } public function provideSelectFields() { @@ -920,6 +935,7 @@ class RevisionTest extends MediaWikiTestCase { 'rev_timestamp', 'rev_user_text', 'rev_user', + 'rev_actor' => 'NULL', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -939,6 +955,7 @@ class RevisionTest extends MediaWikiTestCase { 'rev_timestamp', 'rev_user_text', 'rev_user', + 'rev_actor' => 'NULL', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -956,7 +973,8 @@ class RevisionTest extends MediaWikiTestCase { public function testSelectFields( $contentHandlerUseDB, $expected ) { $this->hideDeprecated( 'Revision::selectFields' ); $this->setMwGlobals( 'wgContentHandlerUseDB', $contentHandlerUseDB ); - $this->overrideCommentStore(); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); + $this->overrideCommentStoreAndActorMigration(); $this->assertEquals( $expected, Revision::selectFields() ); } @@ -972,6 +990,7 @@ class RevisionTest extends MediaWikiTestCase { 'ar_timestamp', 'ar_user_text', 'ar_user', + 'ar_actor' => 'NULL', 'ar_minor_edit', 'ar_deleted', 'ar_len', @@ -993,6 +1012,7 @@ class RevisionTest extends MediaWikiTestCase { 'ar_timestamp', 'ar_user_text', 'ar_user', + 'ar_actor' => 'NULL', 'ar_minor_edit', 'ar_deleted', 'ar_len', @@ -1010,7 +1030,8 @@ class RevisionTest extends MediaWikiTestCase { public function testSelectArchiveFields( $contentHandlerUseDB, $expected ) { $this->hideDeprecated( 'Revision::selectArchiveFields' ); $this->setMwGlobals( 'wgContentHandlerUseDB', $contentHandlerUseDB ); - $this->overrideCommentStore(); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); + $this->overrideCommentStoreAndActorMigration(); $this->assertEquals( $expected, Revision::selectArchiveFields() ); } @@ -1068,6 +1089,7 @@ class RevisionTest extends MediaWikiTestCase { 'tables' => [ 'archive', 'commentstore' => 'table', + 'actormigration' => 'table', ], 'fields' => [ 'ar_id', @@ -1078,16 +1100,17 @@ class RevisionTest extends MediaWikiTestCase { 'ar_text', 'ar_text_id', 'ar_timestamp', - 'ar_user_text', - 'ar_user', 'ar_minor_edit', 'ar_deleted', 'ar_len', 'ar_parent_id', 'ar_sha1', - 'commentstore' => 'field' + 'commentstore' => 'field', + 'ar_user' => 'actormigration_user', + 'ar_user_text' => 'actormigration_user_text', + 'ar_actor' => 'actormigration_actor', ], - 'joins' => [ 'commentstore' => 'join' ], + 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ], ] ]; yield 'wgContentHandlerUseDB true' => [ @@ -1098,6 +1121,7 @@ class RevisionTest extends MediaWikiTestCase { 'tables' => [ 'archive', 'commentstore' => 'table', + 'actormigration' => 'table', ], 'fields' => [ 'ar_id', @@ -1108,18 +1132,19 @@ class RevisionTest extends MediaWikiTestCase { 'ar_text', 'ar_text_id', 'ar_timestamp', - 'ar_user_text', - 'ar_user', 'ar_minor_edit', 'ar_deleted', 'ar_len', 'ar_parent_id', 'ar_sha1', 'commentstore' => 'field', + 'ar_user' => 'actormigration_user', + 'ar_user_text' => 'actormigration_user_text', + 'ar_actor' => 'actormigration_actor', 'ar_content_format', 'ar_content_model', ], - 'joins' => [ 'commentstore' => 'join' ], + 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ], ] ]; } @@ -1130,7 +1155,7 @@ class RevisionTest extends MediaWikiTestCase { */ public function testGetArchiveQueryInfo( $globals, $expected ) { $this->setMwGlobals( $globals ); - $this->overrideCommentStore(); + $this->overrideCommentStoreAndActorMigration(); $revisionStore = $this->getRevisionStore(); $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] ); @@ -1148,22 +1173,23 @@ class RevisionTest extends MediaWikiTestCase { ], [], [ - 'tables' => [ 'revision', 'commentstore' => 'table' ], + 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table' ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', ], - 'joins' => [ 'commentstore' => 'join' ], + 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ], ], ]; yield 'wgContentHandlerUseDB false, opts page' => [ @@ -1172,20 +1198,21 @@ class RevisionTest extends MediaWikiTestCase { ], [ 'page' ], [ - 'tables' => [ 'revision', 'commentstore' => 'table', 'page' ], + 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'page' ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', 'page_namespace', 'page_title', 'page_id', @@ -1199,6 +1226,7 @@ class RevisionTest extends MediaWikiTestCase { [ 'page_id = rev_page' ], ], 'commentstore' => 'join', + 'actormigration' => 'join', ], ], ]; @@ -1208,31 +1236,33 @@ class RevisionTest extends MediaWikiTestCase { ], [ 'user' ], [ - 'tables' => [ 'revision', 'commentstore' => 'table', 'user' ], + 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'user' ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', 'user_name', ], 'joins' => [ 'user' => [ 'LEFT JOIN', [ - 'rev_user != 0', - 'user_id = rev_user', + 'actormigration_user != 0', + 'user_id = actormigration_user', ], ], 'commentstore' => 'join', + 'actormigration' => 'join', ], ], ]; @@ -1242,20 +1272,21 @@ class RevisionTest extends MediaWikiTestCase { ], [ 'text' ], [ - 'tables' => [ 'revision', 'commentstore' => 'table', 'text' ], + 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'text' ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', 'old_text', 'old_flags', ], @@ -1265,6 +1296,7 @@ class RevisionTest extends MediaWikiTestCase { [ 'rev_text_id=old_id' ], ], 'commentstore' => 'join', + 'actormigration' => 'join', ], ], ]; @@ -1274,20 +1306,23 @@ class RevisionTest extends MediaWikiTestCase { ], [ 'text', 'page', 'user' ], [ - 'tables' => [ 'revision', 'commentstore' => 'table', 'page', 'user', 'text' ], + 'tables' => [ + 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'page', 'user', 'text' + ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', 'page_namespace', 'page_title', 'page_id', @@ -1306,8 +1341,8 @@ class RevisionTest extends MediaWikiTestCase { 'user' => [ 'LEFT JOIN', [ - 'rev_user != 0', - 'user_id = rev_user', + 'actormigration_user != 0', + 'user_id = actormigration_user', ], ], 'text' => [ @@ -1315,6 +1350,7 @@ class RevisionTest extends MediaWikiTestCase { [ 'rev_text_id=old_id' ], ], 'commentstore' => 'join', + 'actormigration' => 'join', ], ], ]; @@ -1324,24 +1360,25 @@ class RevisionTest extends MediaWikiTestCase { ], [], [ - 'tables' => [ 'revision', 'commentstore' => 'table' ], + 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table' ], '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', 'commentstore' => 'field', + 'rev_user' => 'actormigration_user', + 'rev_user_text' => 'actormigration_user_text', + 'rev_actor' => 'actormigration_actor', 'rev_content_format', 'rev_content_model', ], - 'joins' => [ 'commentstore' => 'join' ], + 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ], ], ]; } @@ -1352,7 +1389,7 @@ class RevisionTest extends MediaWikiTestCase { */ public function testGetQueryInfo( $globals, $options, $expected ) { $this->setMwGlobals( $globals ); - $this->overrideCommentStore(); + $this->overrideCommentStoreAndActorMigration(); $revisionStore = $this->getRevisionStore(); $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] ); diff --git a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php index 79cac5ebab..807099fe72 100644 --- a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php +++ b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php @@ -60,6 +60,8 @@ class MutableRevisionRecordTest extends MediaWikiTestCase { public function testSimpleGetSlotWhenEmpty() { $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertFalse( $record->hasSlot( 'main' ) ); + $this->setExpectedException( RevisionAccessException::class ); $record->getSlot( 'main' ); } @@ -71,6 +73,7 @@ class MutableRevisionRecordTest extends MediaWikiTestCase { new WikitextContent( 'x' ) ); $record->setSlot( $slot ); + $this->assertTrue( $record->hasSlot( 'main' ) ); $this->assertSame( $slot, $record->getSlot( 'main' ) ); } diff --git a/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php b/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php index c2a275fe8e..0416bcfa33 100644 --- a/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php +++ b/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php @@ -5,13 +5,12 @@ namespace MediaWiki\Tests\Storage; use MediaWiki\Storage\MutableRevisionSlots; use MediaWiki\Storage\RevisionAccessException; use MediaWiki\Storage\SlotRecord; -use MediaWikiTestCase; use WikitextContent; /** * @covers \MediaWiki\Storage\MutableRevisionSlots */ -class MutableRevisionSlotsTest extends MediaWikiTestCase { +class MutableRevisionSlotsTest extends RevisionSlotsTest { public function testSetMultipleSlots() { $slots = new MutableRevisionSlots(); @@ -20,11 +19,13 @@ class MutableRevisionSlotsTest extends MediaWikiTestCase { $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) ); $slots->setSlot( $slotA ); + $this->assertTrue( $slots->hasSlot( 'some' ) ); $this->assertSame( $slotA, $slots->getSlot( 'some' ) ); $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() ); $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) ); $slots->setSlot( $slotB ); + $this->assertTrue( $slots->hasSlot( 'other' ) ); $this->assertSame( $slotB, $slots->getSlot( 'other' ) ); $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() ); } diff --git a/tests/phpunit/includes/Storage/NameTableStoreTest.php b/tests/phpunit/includes/Storage/NameTableStoreTest.php new file mode 100644 index 0000000000..5276a140f6 --- /dev/null +++ b/tests/phpunit/includes/Storage/NameTableStoreTest.php @@ -0,0 +1,298 @@ +tablesUsed[] = 'slot_roles'; + parent::setUp(); + } + + private function populateTable( $values ) { + $insertValues = []; + foreach ( $values as $name ) { + $insertValues[] = [ 'role_name' => $name ]; + } + $this->db->insert( 'slot_roles', $insertValues ); + } + + private function getHashWANObjectCache( $cacheBag ) { + return new WANObjectCache( [ 'cache' => $cacheBag ] ); + } + + /** + * @param $db + * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( $db ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getConnection' ) + ->willReturn( $db ); + return $mock; + } + + private function getCallCheckingDb( $insertCalls, $selectCalls ) { + $mock = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->exactly( $insertCalls ) ) + ->method( 'insert' ) + ->willReturnCallback( function () { + return call_user_func_array( [ $this->db, 'insert' ], func_get_args() ); + } ); + $mock->expects( $this->exactly( $selectCalls ) ) + ->method( 'select' ) + ->willReturnCallback( function () { + return call_user_func_array( [ $this->db, 'select' ], func_get_args() ); + } ); + $mock->expects( $this->exactly( $insertCalls ) ) + ->method( 'affectedRows' ) + ->willReturnCallback( function () { + return call_user_func_array( [ $this->db, 'affectedRows' ], func_get_args() ); + } ); + $mock->expects( $this->any() ) + ->method( 'insertId' ) + ->willReturnCallback( function () { + return call_user_func_array( [ $this->db, 'insertId' ], func_get_args() ); + } ); + return $mock; + } + + private function getNameTableSqlStore( + BagOStuff $cacheBag, + $insertCalls, + $selectCalls, + $normalizationCallback = null + ) { + return new NameTableStore( + $this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ), + $this->getHashWANObjectCache( $cacheBag ), + new NullLogger(), + 'slot_roles', 'role_id', 'role_name', + $normalizationCallback + ); + } + + public function provideGetAndAcquireId() { + return [ + 'no wancache, empty table' => + [ new EmptyBagOStuff(), true, 1, [], 'foo', 1 ], + 'no wancache, one matching value' => + [ new EmptyBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ], + 'no wancache, one not matching value' => + [ new EmptyBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ], + 'no wancache, multiple, one matching value' => + [ new EmptyBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ], + 'no wancache, multiple, no matching value' => + [ new EmptyBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ], + 'wancache, empty table' => + [ new HashBagOStuff(), true, 1, [], 'foo', 1 ], + 'wancache, one matching value' => + [ new HashBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ], + 'wancache, one not matching value' => + [ new HashBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ], + 'wancache, multiple, one matching value' => + [ new HashBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ], + 'wancache, multiple, no matching value' => + [ new HashBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ], + ]; + } + + /** + * @dataProvider provideGetAndAcquireId + * @param BagOStuff $cacheBag to use in the WANObjectCache service + * @param bool $needsInsert Does the value we are testing need to be inserted? + * @param int $selectCalls Number of times the select DB method will be called + * @param string[] $existingValues to be added to the db table + * @param string $name name to acquire + * @param int $expectedId the id we expect the name to have + */ + public function testGetAndAcquireId( + $cacheBag, + $needsInsert, + $selectCalls, + $existingValues, + $name, + $expectedId + ) { + $this->populateTable( $existingValues ); + $store = $this->getNameTableSqlStore( $cacheBag, (int)$needsInsert, $selectCalls ); + + // Some names will not initially exist + try { + $result = $store->getId( $name ); + $this->assertSame( $expectedId, $result ); + } catch ( NameTableAccessException $e ) { + if ( $needsInsert ) { + $this->assertTrue( true ); // Expected exception + } else { + $this->fail( 'Did not expect an exception, but got one: ' . $e->getMessage() ); + } + } + + // All names should return their id here + $this->assertSame( $expectedId, $store->acquireId( $name ) ); + + // acquireId inserted these names, so now everything should exist with getId + $this->assertSame( $expectedId, $store->getId( $name ) ); + + // calling getId again will also still work, and not result in more selects + $this->assertSame( $expectedId, $store->getId( $name ) ); + } + + public function provideTestGetAndAcquireIdNameNormalization() { + yield [ 'A', 'a', 'strtolower' ]; + yield [ 'b', 'B', 'strtoupper' ]; + yield [ + 'X', + 'X', + function ( $name ) { + return $name; + } + ]; + yield [ 'ZZ', 'ZZ-a', __CLASS__ . '::appendDashAToString' ]; + } + + public static function appendDashAToString( $string ) { + return $string . '-a'; + } + + /** + * @dataProvider provideTestGetAndAcquireIdNameNormalization + */ + public function testGetAndAcquireIdNameNormalization( + $nameIn, + $nameOut, + $normalizationCallback + ) { + $store = $this->getNameTableSqlStore( + new EmptyBagOStuff(), + 1, + 1, + $normalizationCallback + ); + $acquiredId = $store->acquireId( $nameIn ); + $this->assertSame( $nameOut, $store->getName( $acquiredId ) ); + } + + public function provideGetName() { + return [ + [ new HashBagOStuff(), 3, 3 ], + [ new EmptyBagOStuff(), 3, 3 ], + ]; + } + + /** + * @dataProvider provideGetName + */ + public function testGetName( $cacheBag, $insertCalls, $selectCalls ) { + $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls ); + + // Get 1 ID and make sure getName returns correctly + $fooId = $store->acquireId( 'foo' ); + $this->assertSame( 'foo', $store->getName( $fooId ) ); + + // Get another ID and make sure getName returns correctly + $barId = $store->acquireId( 'bar' ); + $this->assertSame( 'bar', $store->getName( $barId ) ); + + // Blitz the cache and make sure it still returns + TestingAccessWrapper::newFromObject( $store )->tableCache = null; + $this->assertSame( 'foo', $store->getName( $fooId ) ); + $this->assertSame( 'bar', $store->getName( $barId ) ); + + // Blitz the cache again and get another ID and make sure getName returns correctly + TestingAccessWrapper::newFromObject( $store )->tableCache = null; + $bazId = $store->acquireId( 'baz' ); + $this->assertSame( 'baz', $store->getName( $bazId ) ); + $this->assertSame( 'baz', $store->getName( $bazId ) ); + } + + public function testGetName_masterFallback() { + $store = $this->getNameTableSqlStore( new EmptyBagOStuff(), 1, 2 ); + + // Insert a new name + $fooId = $store->acquireId( 'foo' ); + + // Empty the process cache, getCachedTable() will now return this empty array + TestingAccessWrapper::newFromObject( $store )->tableCache = []; + + // getName should fallback to master, which is why we assert 2 selectCalls above + $this->assertSame( 'foo', $store->getName( $fooId ) ); + } + + public function testGetMap_empty() { + $this->populateTable( [] ); + $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 ); + $table = $store->getMap(); + $this->assertSame( [], $table ); + } + + public function testGetMap_twoValues() { + $this->populateTable( [ 'foo', 'bar' ] ); + $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 ); + + // We are using a cache, so 2 calls should only result in 1 select on the db + $store->getMap(); + $table = $store->getMap(); + + $expected = [ 2 => 'bar', 1 => 'foo' ]; + $this->assertSame( $expected, $table ); + // Make sure the table returned is the same as the cached table + $this->assertSame( $expected, TestingAccessWrapper::newFromObject( $store )->tableCache ); + } + + public function testCacheRaceCondition() { + $wanHashBag = new HashBagOStuff(); + $store1 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 ); + $store2 = $this->getNameTableSqlStore( $wanHashBag, 1, 0 ); + $store3 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 ); + + // Cache the current table in the instances we will use + // This simulates multiple requests running simultaneously + $store1->getMap(); + $store2->getMap(); + $store3->getMap(); + + // Store 2 separate names using different instances + $fooId = $store1->acquireId( 'foo' ); + $barId = $store2->acquireId( 'bar' ); + + // Each of these instances should be aware of what they have inserted + $this->assertSame( $fooId, $store1->acquireId( 'foo' ) ); + $this->assertSame( $barId, $store2->acquireId( 'bar' ) ); + + // A new store should be able to get both of these new Ids + // Note: before there was a race condition here where acquireId( 'bar' ) would update the + // cache with data missing the 'foo' key that it was not aware of + $store4 = $this->getNameTableSqlStore( $wanHashBag, 0, 1 ); + $this->assertSame( $fooId, $store4->getId( 'foo' ) ); + $this->assertSame( $barId, $store4->getId( 'bar' ) ); + + // If a store with old cached data tries to acquire these we will get the same ids. + $this->assertSame( $fooId, $store3->acquireId( 'foo' ) ); + $this->assertSame( $barId, $store3->acquireId( 'bar' ) ); + } + +} diff --git a/tests/phpunit/includes/Storage/RevisionSlotsTest.php b/tests/phpunit/includes/Storage/RevisionSlotsTest.php index 4dfae4bb8d..b9f833caa6 100644 --- a/tests/phpunit/includes/Storage/RevisionSlotsTest.php +++ b/tests/phpunit/includes/Storage/RevisionSlotsTest.php @@ -10,13 +10,21 @@ use WikitextContent; class RevisionSlotsTest extends MediaWikiTestCase { + /** + * @param SlotRecord[] $slots + * @return RevisionSlots + */ + protected function newRevisionSlots( $slots = [] ) { + return new RevisionSlots( $slots ); + } + /** * @covers \MediaWiki\Storage\RevisionSlots::getSlot */ public function testGetSlot() { $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) ); $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); $this->assertSame( $mainSlot, $slots->getSlot( 'main' ) ); $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) ); @@ -24,6 +32,20 @@ class RevisionSlotsTest extends MediaWikiTestCase { $slots->getSlot( 'nothere' ); } + /** + * @covers \MediaWiki\Storage\RevisionSlots::hasSlot + */ + public function testHasSlot() { + $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) ); + $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); + + $this->assertTrue( $slots->hasSlot( 'main' ) ); + $this->assertTrue( $slots->hasSlot( 'aux' ) ); + $this->assertFalse( $slots->hasSlot( 'AUX' ) ); + $this->assertFalse( $slots->hasSlot( 'xyz' ) ); + } + /** * @covers \MediaWiki\Storage\RevisionSlots::getContent */ @@ -32,7 +54,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { $auxContent = new WikitextContent( 'B' ); $mainSlot = SlotRecord::newUnsaved( 'main', $mainContent ); $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent ); - $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); $this->assertSame( $mainContent, $slots->getContent( 'main' ) ); $this->assertSame( $auxContent, $slots->getContent( 'aux' ) ); @@ -46,7 +68,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { public function testGetSlotRoles_someSlots() { $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) ); $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); - $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] ); + $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] ); $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() ); } @@ -55,7 +77,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles */ public function testGetSlotRoles_noSlots() { - $slots = new RevisionSlots( [] ); + $slots = $this->newRevisionSlots( [] ); $this->assertSame( [], $slots->getSlotRoles() ); } @@ -67,7 +89,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) ); $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) ); $slotsArray = [ $mainSlot, $auxSlot ]; - $slots = new RevisionSlots( $slotsArray ); + $slots = $this->newRevisionSlots( $slotsArray ); $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() ); } @@ -87,7 +109,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { foreach ( $contentStrings as $key => $contentString ) { $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); } - $slots = new RevisionSlots( $slotsArray ); + $slots = $this->newRevisionSlots( $slotsArray ); $this->assertSame( $expected, $slots->computeSize() ); } @@ -109,7 +131,7 @@ class RevisionSlotsTest extends MediaWikiTestCase { foreach ( $contentStrings as $key => $contentString ) { $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) ); } - $slots = new RevisionSlots( $slotsArray ); + $slots = $this->newRevisionSlots( $slotsArray ); $this->assertSame( $expected, $slots->computeSha1() ); } diff --git a/tests/phpunit/includes/Storage/RevisionStoreDbTest.php b/tests/phpunit/includes/Storage/RevisionStoreDbTest.php index d31ca5c395..e81f0afea2 100644 --- a/tests/phpunit/includes/Storage/RevisionStoreDbTest.php +++ b/tests/phpunit/includes/Storage/RevisionStoreDbTest.php @@ -134,6 +134,7 @@ class RevisionStoreDbTest extends MediaWikiTestCase { $blobStore, new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ), MediaWikiServices::getInstance()->getCommentStore(), + MediaWikiServices::getInstance()->getActorMigration(), $wikiId ); @@ -621,12 +622,15 @@ class RevisionStoreDbTest extends MediaWikiTestCase { * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29 */ public function testNewRevisionFromRow_anonEdit() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); $text = __METHOD__ . 'a-ä'; /** @var Revision $rev */ $rev = $page->doEditContent( new WikitextContent( $text ), - __METHOD__. 'a' + __METHOD__ . 'a' )->value['revision']; $store = MediaWikiServices::getInstance()->getRevisionStore(); @@ -669,6 +673,9 @@ class RevisionStoreDbTest extends MediaWikiTestCase { * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29 */ public function testNewRevisionFromRow_userEdit() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); $text = __METHOD__ . 'b-ä'; /** @var Revision $rev */ @@ -1048,7 +1055,7 @@ class RevisionStoreDbTest extends MediaWikiTestCase { $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); /** @var Revision $rev */ $rev = $page->doEditContent( - new WikitextContent( __METHOD__. 'b' ), + new WikitextContent( __METHOD__ . 'b' ), __METHOD__ . 'b', 0, false, diff --git a/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php b/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php index aa59a5b50b..3976995574 100644 --- a/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php +++ b/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php @@ -30,7 +30,7 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { $title = Title::newFromText( 'Dummy' ); $title->resetArticleID( 17 ); - $user = new UserIdentityValue( 11, 'Tester' ); + $user = new UserIdentityValue( 11, 'Tester', 0 ); $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) ); @@ -58,7 +58,7 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { $title = Title::newFromText( 'Dummy' ); $title->resetArticleID( 17 ); - $user = new UserIdentityValue( 11, 'Tester' ); + $user = new UserIdentityValue( 11, 'Tester', 0 ); $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) ); @@ -213,7 +213,7 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { $title = Title::newFromText( 'Dummy' ); $title->resetArticleID( 17 ); - $user = new UserIdentityValue( 11, 'Tester' ); + $user = new UserIdentityValue( 11, 'Tester', 0 ); $comment = CommentStoreComment::newUnsavedComment( 'Hello World' ); @@ -458,8 +458,9 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] ); // NOTE: slot meta-data is never suppressed, just the content is! - $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw can' ); - $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public can' ); + $this->assertTrue( $rev->hasSlot( 'main' ), 'hasSlot is never suppressed' ); + $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw meta' ); + $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public meta' ); $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user ), @@ -562,6 +563,13 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { $this->assertSame( 'main', $slot->getRole(), 'getRole()' ); } + public function testHasSlot() { + $rev = $this->newRevision(); + + $this->assertTrue( $rev->hasSlot( 'main' ) ); + $this->assertFalse( $rev->hasSlot( 'xyz' ) ); + } + public function testGetContent() { $rev = $this->newRevision(); @@ -688,7 +696,7 @@ class RevisionStoreRecordTest extends MediaWikiTestCase { return new RevisionStoreRecord( $title, - new UserIdentityValue( 11, __METHOD__ ), + new UserIdentityValue( 11, __METHOD__, 0 ), CommentStoreComment::newUnsavedComment( __METHOD__ ), (object)[ 'rev_id' => strval( $revId ), diff --git a/tests/phpunit/includes/Storage/RevisionStoreTest.php b/tests/phpunit/includes/Storage/RevisionStoreTest.php index 8e8de6ea31..849894755b 100644 --- a/tests/phpunit/includes/Storage/RevisionStoreTest.php +++ b/tests/phpunit/includes/Storage/RevisionStoreTest.php @@ -32,7 +32,8 @@ class RevisionStoreTest extends MediaWikiTestCase { $loadBalancer ? $loadBalancer : $this->getMockLoadBalancer(), $blobStore ? $blobStore : $this->getMockSqlBlobStore(), $WANObjectCache ? $WANObjectCache : $this->getHashWANObjectCache(), - MediaWikiServices::getInstance()->getCommentStore() + MediaWikiServices::getInstance()->getCommentStore(), + MediaWikiServices::getInstance()->getActorMigration() ); } @@ -83,8 +84,6 @@ class RevisionStoreTest extends MediaWikiTestCase { 'rev_page', 'rev_text_id', 'rev_timestamp', - 'rev_user_text', - 'rev_user', 'rev_minor_edit', 'rev_deleted', 'rev_len', @@ -101,6 +100,14 @@ class RevisionStoreTest extends MediaWikiTestCase { ]; } + private function getActorQueryFields() { + return [ + 'rev_user' => 'rev_user', + 'rev_user_text' => 'rev_user_text', + 'rev_actor' => 'NULL', + ]; + } + private function getContentHandlerQueryFields() { return [ 'rev_content_format', @@ -117,6 +124,7 @@ class RevisionStoreTest extends MediaWikiTestCase { 'fields' => array_merge( $this->getDefaultQueryFields(), $this->getCommentQueryFields(), + $this->getActorQueryFields(), $this->getContentHandlerQueryFields() ), 'joins' => [], @@ -129,7 +137,8 @@ class RevisionStoreTest extends MediaWikiTestCase { 'tables' => [ 'revision' ], 'fields' => array_merge( $this->getDefaultQueryFields(), - $this->getCommentQueryFields() + $this->getCommentQueryFields(), + $this->getActorQueryFields() ), 'joins' => [], ] @@ -142,6 +151,7 @@ class RevisionStoreTest extends MediaWikiTestCase { 'fields' => array_merge( $this->getDefaultQueryFields(), $this->getCommentQueryFields(), + $this->getActorQueryFields(), [ 'page_namespace', 'page_title', @@ -164,6 +174,7 @@ class RevisionStoreTest extends MediaWikiTestCase { 'fields' => array_merge( $this->getDefaultQueryFields(), $this->getCommentQueryFields(), + $this->getActorQueryFields(), [ 'user_name', ] @@ -181,6 +192,7 @@ class RevisionStoreTest extends MediaWikiTestCase { 'fields' => array_merge( $this->getDefaultQueryFields(), $this->getCommentQueryFields(), + $this->getActorQueryFields(), [ 'old_text', 'old_flags', @@ -199,6 +211,7 @@ class RevisionStoreTest extends MediaWikiTestCase { 'fields' => array_merge( $this->getDefaultQueryFields(), $this->getCommentQueryFields(), + $this->getActorQueryFields(), $this->getContentHandlerQueryFields(), [ 'page_namespace', @@ -227,6 +240,7 @@ class RevisionStoreTest extends MediaWikiTestCase { */ public function testGetQueryInfo( $contentHandlerUseDb, $options, $expected ) { $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); $this->overrideMwServices(); $store = $this->getRevisionStore(); $store->setContentHandlerUseDB( $contentHandlerUseDb ); @@ -243,8 +257,6 @@ class RevisionStoreTest extends MediaWikiTestCase { 'ar_text', 'ar_text_id', 'ar_timestamp', - 'ar_user_text', - 'ar_user', 'ar_minor_edit', 'ar_deleted', 'ar_len', @@ -258,6 +270,7 @@ class RevisionStoreTest extends MediaWikiTestCase { */ public function testGetArchiveQueryInfo_contentHandlerDb() { $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); $this->overrideMwServices(); $store = $this->getRevisionStore(); $store->setContentHandlerUseDB( true ); @@ -272,6 +285,9 @@ class RevisionStoreTest extends MediaWikiTestCase { 'ar_comment_text' => 'ar_comment', 'ar_comment_data' => 'NULL', 'ar_comment_cid' => 'NULL', + 'ar_user_text' => 'ar_user_text', + 'ar_user' => 'ar_user', + 'ar_actor' => 'NULL', 'ar_content_format', 'ar_content_model', ] @@ -287,6 +303,7 @@ class RevisionStoreTest extends MediaWikiTestCase { */ public function testGetArchiveQueryInfo_noContentHandlerDb() { $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD ); + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD ); $this->overrideMwServices(); $store = $this->getRevisionStore(); $store->setContentHandlerUseDB( false ); @@ -301,6 +318,9 @@ class RevisionStoreTest extends MediaWikiTestCase { 'ar_comment_text' => 'ar_comment', 'ar_comment_data' => 'NULL', 'ar_comment_cid' => 'NULL', + 'ar_user_text' => 'ar_user_text', + 'ar_user' => 'ar_user', + 'ar_actor' => 'NULL', ] ), 'joins' => [], diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php index f4eb6bf5d3..9ae84d920b 100644 --- a/tests/phpunit/includes/TitleMethodsTest.php +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -164,7 +164,7 @@ class TitleMethodsTest extends MediaWikiLangTestCase { $this->assertTrue( $title->hasContentModel( $expectedModelId ) ); } - public static function provideIsCssOrJsPage() { + public static function provideIsSiteConfigPage() { return [ [ 'Help:Foo', false ], [ 'Help:Foo.js', false ], @@ -173,6 +173,8 @@ class TitleMethodsTest extends MediaWikiLangTestCase { [ 'User:Foo.js', false ], [ 'User:Foo/bar.js', false ], [ 'User:Foo/bar.css', false ], + [ 'User:Foo/bar.JS', false ], + [ 'User:Foo/bar.CSS', false ], [ 'User talk:Foo/bar.css', false ], [ 'User:Foo/bar.js.xxx', false ], [ 'User:Foo/bar.xxx', false ], @@ -180,6 +182,7 @@ class TitleMethodsTest extends MediaWikiLangTestCase { [ 'MediaWiki:Foo.css', true ], [ 'MediaWiki:Foo.JS', false ], [ 'MediaWiki:Foo.CSS', false ], + [ 'MediaWiki:Foo/bar.css', true ], [ 'MediaWiki:Foo.css.xxx', false ], [ 'TEST-JS:Foo', false ], [ 'TEST-JS:Foo.js', false ], @@ -187,15 +190,15 @@ class TitleMethodsTest extends MediaWikiLangTestCase { } /** - * @dataProvider provideIsCssOrJsPage - * @covers Title::isCssOrJsPage + * @dataProvider provideIsSiteConfigPage + * @covers Title::isSiteConfigPage */ - public function testIsCssOrJsPage( $title, $expectedBool ) { + public function testSiteConfigPage( $title, $expectedBool ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expectedBool, $title->isCssOrJsPage() ); + $this->assertEquals( $expectedBool, $title->isSiteConfigPage() ); } - public static function provideIsCssJsSubpage() { + public static function provideIsUserConfigPage() { return [ [ 'Help:Foo', false ], [ 'Help:Foo.js', false ], @@ -203,28 +206,32 @@ class TitleMethodsTest extends MediaWikiLangTestCase { [ 'User:Foo', false ], [ 'User:Foo.js', false ], [ 'User:Foo/bar.js', true ], + [ 'User:Foo/bar.JS', false ], [ 'User:Foo/bar.css', true ], + [ 'User:Foo/bar.CSS', false ], [ 'User talk:Foo/bar.css', false ], [ 'User:Foo/bar.js.xxx', false ], [ 'User:Foo/bar.xxx', false ], [ 'MediaWiki:Foo.js', false ], - [ 'User:Foo/bar.JS', false ], - [ 'User:Foo/bar.CSS', false ], + [ 'MediaWiki:Foo.css', false ], + [ 'MediaWiki:Foo.JS', false ], + [ 'MediaWiki:Foo.CSS', false ], + [ 'MediaWiki:Foo.css.xxx', false ], [ 'TEST-JS:Foo', false ], [ 'TEST-JS:Foo.js', false ], ]; } /** - * @dataProvider provideIsCssJsSubpage - * @covers Title::isCssJsSubpage + * @dataProvider provideIsUserConfigPage + * @covers Title::isUserConfigPage */ - public function testIsCssJsSubpage( $title, $expectedBool ) { + public function testIsUserConfigPage( $title, $expectedBool ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expectedBool, $title->isCssJsSubpage() ); + $this->assertEquals( $expectedBool, $title->isUserConfigPage() ); } - public static function provideIsCssSubpage() { + public static function provideIsUserCssConfigPage() { return [ [ 'Help:Foo', false ], [ 'Help:Foo.css', false ], @@ -237,33 +244,35 @@ class TitleMethodsTest extends MediaWikiLangTestCase { } /** - * @dataProvider provideIsCssSubpage - * @covers Title::isCssSubpage + * @dataProvider provideIsUserCssConfigPage + * @covers Title::isUserCssConfigPage */ - public function testIsCssSubpage( $title, $expectedBool ) { + public function testIsUserCssConfigPage( $title, $expectedBool ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expectedBool, $title->isCssSubpage() ); + $this->assertEquals( $expectedBool, $title->isUserCssConfigPage() ); } - public static function provideIsJsSubpage() { + public static function provideIsUserJsConfigPage() { return [ [ 'Help:Foo', false ], [ 'Help:Foo.css', false ], [ 'User:Foo', false ], [ 'User:Foo.js', false ], + [ 'User:Foo.json', false ], [ 'User:Foo.css', false ], [ 'User:Foo/bar.js', true ], + [ 'User:Foo/bar.json', false ], [ 'User:Foo/bar.css', false ], ]; } /** - * @dataProvider provideIsJsSubpage - * @covers Title::isJsSubpage + * @dataProvider provideIsUserJsConfigPage + * @covers Title::isUserJsConfigPage */ - public function testIsJsSubpage( $title, $expectedBool ) { + public function testIsUserJsConfigPage( $title, $expectedBool ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expectedBool, $title->isJsSubpage() ); + $this->assertEquals( $expectedBool, $title->isUserJsConfigPage() ); } public static function provideIsWikitextPage() { @@ -279,13 +288,14 @@ class TitleMethodsTest extends MediaWikiLangTestCase { [ 'User:Foo/bar.js.xxx', true ], [ 'User:Foo/bar.xxx', true ], [ 'MediaWiki:Foo.js', false ], - [ 'MediaWiki:Foo.css', false ], - [ 'MediaWiki:Foo/bar.css', false ], [ 'User:Foo/bar.JS', true ], [ 'User:Foo/bar.CSS', true ], + [ 'MediaWiki:Foo.css', false ], + [ 'MediaWiki:Foo.JS', true ], + [ 'MediaWiki:Foo.CSS', true ], + [ 'MediaWiki:Foo.css.xxx', true ], [ 'TEST-JS:Foo', false ], [ 'TEST-JS:Foo.js', false ], - [ 'TEST-JS_TALK:Foo.js', true ], ]; } diff --git a/tests/phpunit/includes/TitlePermissionTest.php b/tests/phpunit/includes/TitlePermissionTest.php index e20cc7b12f..7dfb7357f5 100644 --- a/tests/phpunit/includes/TitlePermissionTest.php +++ b/tests/phpunit/includes/TitlePermissionTest.php @@ -96,6 +96,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { /** * @todo This test method should be split up into separate test methods and * data providers + * @covers Title::checkQuickPermissions */ public function testQuickPermissions() { global $wgContLang; @@ -386,6 +387,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { /** * @todo This test method should be split up into separate test methods and * data providers + * @covers Title::checkSpecialsAndNSPermissions */ public function testSpecialsAndNSPermissions() { global $wgNamespaceProtection; @@ -442,91 +444,139 @@ class TitlePermissionTest extends MediaWikiLangTestCase { /** * @todo This test method should be split up into separate test methods and * data providers + * @covers Title::checkUserConfigPermissions */ - public function testCssAndJavascriptPermissions() { + public function testJsConfigEditPermissions() { $this->setUser( $this->userName ); $this->setTitle( NS_USER, $this->userName . '/test.js' ); - $this->runCSSandJSPermissions( + $this->runConfigEditPermissions( [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ], [ [ 'badaccess-group0' ] ], + [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ], [ [ 'badaccess-group0' ] ] ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + * @covers Title::checkUserConfigPermissions + */ + public function testCssConfigEditPermissions() { + $this->setUser( $this->userName ); $this->setTitle( NS_USER, $this->userName . '/test.css' ); - $this->runCSSandJSPermissions( + $this->runConfigEditPermissions( [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ] ], [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ] ], [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ] ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + * @covers Title::checkUserConfigPermissions + */ + public function testOtherJsConfigEditPermissions() { + $this->setUser( $this->userName ); $this->setTitle( NS_USER, $this->altUserName . '/test.js' ); - $this->runCSSandJSPermissions( + $this->runConfigEditPermissions( [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ], [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ], [ [ 'badaccess-group0' ] ] ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + * @covers Title::checkUserConfigPermissions + */ + public function testOtherCssConfigEditPermissions() { + $this->setUser( $this->userName ); $this->setTitle( NS_USER, $this->altUserName . '/test.css' ); - $this->runCSSandJSPermissions( + $this->runConfigEditPermissions( [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ], [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ], + [ [ 'badaccess-group0' ] ], [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ] ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + * @covers Title::checkUserConfigPermissions + */ + public function testOtherNonConfigEditPermissions() { + $this->setUser( $this->userName ); $this->setTitle( NS_USER, $this->altUserName . '/tempo' ); - $this->runCSSandJSPermissions( + $this->runConfigEditPermissions( [ [ 'badaccess-group0' ] ], + [ [ 'badaccess-group0' ] ], [ [ 'badaccess-group0' ] ], + [ [ 'badaccess-group0' ] ], [ [ 'badaccess-group0' ] ] ); } - protected function runCSSandJSPermissions( $result0, $result1, $result2, $result3, $result4 ) { + protected function runConfigEditPermissions( + $resultNone, + $resultMyCss, + $resultMyJs, + $resultUserCss, + $resultUserJs + ) { $this->setUserPerm( '' ); - $this->assertEquals( $result0, - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( $resultNone, $result ); $this->setUserPerm( 'editmyusercss' ); - $this->assertEquals( $result1, - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( $resultMyCss, $result ); $this->setUserPerm( 'editmyuserjs' ); - $this->assertEquals( $result2, - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( $resultMyJs, $result ); $this->setUserPerm( 'editusercss' ); - $this->assertEquals( $result3, - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( $resultUserCss, $result ); $this->setUserPerm( 'edituserjs' ); - $this->assertEquals( $result4, - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( $resultUserJs, $result ); $this->setUserPerm( [ 'edituserjs', 'editusercss' ] ); - $this->assertEquals( [ [ 'badaccess-group0' ] ], - $this->title->getUserPermissionsErrors( 'bogus', - $this->user ) ); + $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); + $this->assertEquals( [ [ 'badaccess-group0' ] ], $result ); } /** * @todo This test method should be split up into separate test methods and * data providers + * @covers Title::checkPageRestrictions */ public function testPageRestrictions() { global $wgContLang; @@ -619,6 +669,9 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->user ) ); } + /** + * @covers Title::checkCascadingSourcesRestrictions + */ public function testCascadingSourcesRestrictions() { $this->setTitle( NS_MAIN, "test page" ); $this->setUserPerm( [ "edit", "bogus" ] ); @@ -648,6 +701,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { /** * @todo This test method should be split up into separate test methods and * data providers + * @covers Title::checkActionPermissions */ public function testActionPermissions() { $this->setUserPerm( [ "createpage" ] ); @@ -720,6 +774,9 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->userCan( 'move-target', $this->user ) ); } + /** + * @covers Title::checkUserBlock + */ public function testUserBlock() { global $wgEmailConfirmToEdit, $wgEmailAuthentication; $wgEmailConfirmToEdit = true; diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php index 575f0c96d6..3f6cac9a00 100644 --- a/tests/phpunit/includes/api/ApiBaseTest.php +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -157,6 +157,7 @@ class ApiBaseTest extends ApiTestCase { $block = new \Block( [ 'address' => $user->getName(), 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => __METHOD__, 'expiry' => time() + 100500, ] ); diff --git a/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php index 19f66fa9f8..24b7500983 100644 --- a/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php @@ -589,7 +589,7 @@ class ApiQueryRecentChangesIntegrationTest extends ApiTestCase { 'rc_minor' => 0, 'rc_cur_id' => $title->getArticleID(), 'rc_user' => 0, - 'rc_user_text' => 'External User', + 'rc_user_text' => 'm>External User', 'rc_comment' => '', 'rc_comment_text' => '', 'rc_comment_data' => null, diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php index fdbededee4..8919c5e7eb 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -1072,7 +1072,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { 'rc_minor' => 0, 'rc_cur_id' => $title->getArticleID(), 'rc_user' => 0, - 'rc_user_text' => 'External User', + 'rc_user_text' => 'ext>External User', 'rc_comment' => '', 'rc_comment_text' => '', 'rc_comment_data' => null, diff --git a/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php b/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php new file mode 100644 index 0000000000..ca6a929ac8 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php @@ -0,0 +1,194 @@ +overrideMwServices(); + }, [ $wgActorTableSchemaMigrationStage ] ); + $wgActorTableSchemaMigrationStage = MIGRATION_WRITE_BOTH; + $this->overrideMwServices(); + + $users = [ + User::newFromName( '192.168.2.2', false ), + User::newFromName( '192.168.2.1', false ), + User::newFromName( '192.168.2.3', false ), + User::createNew( __CLASS__ . ' B' ), + User::createNew( __CLASS__ . ' A' ), + User::createNew( __CLASS__ . ' C' ), + User::newFromName( 'IW>' . __CLASS__, false ), + ]; + + $title = Title::newFromText( __CLASS__ ); + $page = WikiPage::factory( $title ); + for ( $i = 0; $i < 3; $i++ ) { + foreach ( array_reverse( $users ) as $user ) { + $status = $page->doEditContent( + ContentHandler::makeContent( "Test revision $user #$i", $title ), 'Test edit', 0, false, $user + ); + if ( !$status->isOK() ) { + $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) ); + } + } + } + } + + /** + * @dataProvider provideSorting + * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage + * @param array $params Extra parameters for the query + * @param bool $reverse Reverse order? + * @param int $revs Number of revisions to expect + */ + public function testSorting( $stage, $params, $reverse, $revs ) { + if ( isset( $params['ucuserprefix'] ) && + ( $stage === MIGRATION_WRITE_BOTH || $stage === MIGRATION_WRITE_NEW ) && + $this->db->getType() === 'mysql' && $this->usesTemporaryTables() + ) { + // https://bugs.mysql.com/bug.php?id=10327 + $this->markTestSkipped( 'MySQL bug 10327 - can\'t reopen temporary tables' ); + } + + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage ); + $this->overrideMwServices(); + + if ( isset( $params['ucuserids'] ) ) { + $params['ucuserids'] = implode( '|', array_map( 'User::idFromName', $params['ucuserids'] ) ); + } + if ( isset( $params['ucuser'] ) ) { + $params['ucuser'] = implode( '|', $params['ucuser'] ); + } + + $sort = 'rsort'; + if ( $reverse ) { + $params['ucdir'] = 'newer'; + $sort = 'sort'; + } + + $params += [ + 'action' => 'query', + 'list' => 'usercontribs', + 'ucprop' => 'ids', + ]; + + $apiResult = $this->doApiRequest( $params + [ 'uclimit' => 500 ] ); + $this->assertArrayNotHasKey( 'continue', $apiResult[0] ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] ); + + $count = 0; + $ids = []; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $ids[$page['user']][] = $page['revid']; + } + $this->assertSame( $revs, $count, 'Expected number of revisions' ); + foreach ( $ids as $user => $revids ) { + $sorted = $revids; + call_user_func_array( $sort, [ &$sorted ] ); + $this->assertSame( $sorted, $revids, "IDs for $user are sorted" ); + } + + for ( $limit = 1; $limit < $revs; $limit++ ) { + $continue = []; + $count = 0; + $batchedIds = []; + while ( $continue !== null ) { + $apiResult = $this->doApiRequest( $params + [ 'uclimit' => $limit ] + $continue ); + $this->assertArrayHasKey( 'query', $apiResult[0], "Batching with limit $limit" ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'], + "Batching with limit $limit" ); + $continue = isset( $apiResult[0]['continue'] ) ? $apiResult[0]['continue'] : null; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $batchedIds[$page['user']][] = $page['revid']; + } + $this->assertLessThanOrEqual( $revs, $count, "Batching with limit $limit" ); + } + $this->assertSame( $ids, $batchedIds, "Result set is the same when batching with limit $limit" ); + } + } + + public static function provideSorting() { + $users = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' C' ]; + $users2 = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' D' ]; + $ips = [ '192.168.2.1', '192.168.2.2', '192.168.2.3', '192.168.2.4' ]; + + foreach ( + [ + 'old' => MIGRATION_OLD, + 'write both' => MIGRATION_WRITE_BOTH, + 'write new' => MIGRATION_WRITE_NEW, + 'new' => MIGRATION_NEW, + ] as $stageName => $stage + ) { + foreach ( [ false, true ] as $reverse ) { + $name = $stageName . ( $reverse ? ', reverse' : '' ); + yield "Named users, $name" => [ $stage, [ 'ucuser' => $users ], $reverse, 9 ]; + yield "Named users including a no-edit user, $name" => [ + $stage, [ 'ucuser' => $users2 ], $reverse, 6 + ]; + yield "IP users, $name" => [ $stage, [ 'ucuser' => $ips ], $reverse, 9 ]; + yield "All users, $name" => [ + $stage, [ 'ucuser' => array_merge( $users, $ips ) ], $reverse, 18 + ]; + yield "User IDs, $name" => [ $stage, [ 'ucuserids' => $users ], $reverse, 9 ]; + yield "Users by prefix, $name" => [ $stage, [ 'ucuserprefix' => __CLASS__ ], $reverse, 9 ]; + yield "IPs by prefix, $name" => [ $stage, [ 'ucuserprefix' => '192.168.2.' ], $reverse, 9 ]; + } + } + } + + /** + * @dataProvider provideInterwikiUser + * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage + */ + public function testInterwikiUser( $stage ) { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage ); + $this->overrideMwServices(); + + $params = [ + 'action' => 'query', + 'list' => 'usercontribs', + 'ucuser' => 'IW>' . __CLASS__, + 'ucprop' => 'ids', + 'uclimit' => 'max', + ]; + + $apiResult = $this->doApiRequest( $params ); + $this->assertArrayNotHasKey( 'continue', $apiResult[0] ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] ); + + $count = 0; + $ids = []; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $this->assertSame( 'IW>' . __CLASS__, $page['user'], 'Correct user returned' ); + $ids[] = $page['revid']; + } + $this->assertSame( 3, $count, 'Expected number of revisions' ); + $sorted = $ids; + rsort( $sorted ); + $this->assertSame( $sorted, $ids, "IDs are sorted" ); + } + + public static function provideInterwikiUser() { + return [ + 'old' => [ MIGRATION_OLD ], + 'write both' => [ MIGRATION_WRITE_BOTH ], + 'write new' => [ MIGRATION_WRITE_NEW ], + 'new' => [ MIGRATION_NEW ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php index e4056ee712..211eba00b7 100644 --- a/tests/phpunit/includes/auth/AuthManagerTest.php +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -1436,6 +1436,7 @@ class AuthManagerTest extends \MediaWikiTestCase { $blockOptions = [ 'address' => 'UTBlockee', 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => __METHOD__, 'expiry' => time() + 100500, 'createAccount' => true, @@ -1448,6 +1449,7 @@ class AuthManagerTest extends \MediaWikiTestCase { $blockOptions = [ 'address' => '127.0.0.0/24', + 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => __METHOD__, 'expiry' => time() + 100500, 'createAccount' => true, diff --git a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php index 4e44547bbf..81cdc9dec9 100644 --- a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php +++ b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php @@ -76,6 +76,7 @@ class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase $blockOptions = [ 'address' => 'UTBlockee', 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => __METHOD__, 'expiry' => time() + 100500, 'createAccount' => true, @@ -149,6 +150,7 @@ class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase $blockOptions = [ 'address' => '127.0.0.0/24', 'reason' => __METHOD__, + 'by' => $this->getTestSysop()->getUser()->getId(), 'expiry' => time() + 100500, 'createAccount' => true, ]; diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php index ab4269659c..333eb28610 100644 --- a/tests/phpunit/includes/changes/RecentChangeTest.php +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -27,12 +27,16 @@ class RecentChangeTest extends MediaWikiTestCase { * @covers RecentChange::loadFromRow */ public function testNewFromRow() { + $user = $this->getTestUser()->getUser(); + $actorId = $user->getActorId(); + $row = new stdClass(); $row->rc_foo = 'AAA'; $row->rc_timestamp = '20150921134808'; $row->rc_deleted = 'bar'; $row->rc_comment_text = 'comment'; $row->rc_comment_data = null; + $row->rc_user = $user->getId(); $rc = RecentChange::newFromRow( $row ); @@ -43,6 +47,9 @@ class RecentChangeTest extends MediaWikiTestCase { 'rc_comment' => 'comment', 'rc_comment_text' => 'comment', 'rc_comment_data' => null, + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_actor' => $actorId, ]; $this->assertEquals( $expected, $rc->getAttributes() ); @@ -51,6 +58,7 @@ class RecentChangeTest extends MediaWikiTestCase { $row->rc_timestamp = '20150921134808'; $row->rc_deleted = 'bar'; $row->rc_comment = 'comment'; + $row->rc_user = $user->getId(); Wikimedia\suppressWarnings(); $rc = RecentChange::newFromRow( $row ); @@ -63,6 +71,9 @@ class RecentChangeTest extends MediaWikiTestCase { 'rc_comment' => 'comment', 'rc_comment_text' => 'comment', 'rc_comment_data' => null, + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_actor' => $actorId, ]; $this->assertEquals( $expected, $rc->getAttributes() ); } diff --git a/tests/phpunit/includes/config/EtcdConfigTest.php b/tests/phpunit/includes/config/EtcdConfigTest.php index c833934bfd..07dbd002c1 100644 --- a/tests/phpunit/includes/config/EtcdConfigTest.php +++ b/tests/phpunit/includes/config/EtcdConfigTest.php @@ -17,14 +17,23 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ->getMock(); } - private function createSimpleConfigMock( array $config ) { + private static function createEtcdResponse( array $response ) { + $baseResponse = [ + 'config' => null, + 'error' => null, + 'retry' => false, + 'modifiedIndex' => 0, + ]; + return array_merge( $baseResponse, $response ); + } + + private function createSimpleConfigMock( array $config, $index = 0 ) { $mock = $this->createConfigMock(); $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ - $config, - null, // error - false // retry? - ] ); + ->willReturn( self::createEtcdResponse( [ + 'config' => $config, + 'modifiedIndex' => $index, + ] ) ); return $mock; } @@ -70,6 +79,17 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { $config->get( 'unknown' ); } + /** + * @covers EtcdConfig::getModifiedIndex + */ + public function testGetModifiedIndex() { + $config = $this->createSimpleConfigMock( + [ 'some' => 'value' ], + 123 + ); + $this->assertSame( 123, $config->getModifiedIndex() ); + } + /** * @covers EtcdConfig::__construct */ @@ -81,6 +101,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ->willReturn( [ 'config' => [ 'known' => 'from-cache' ], 'expires' => INF, + 'modifiedIndex' => 123 ] ); $config = $this->createConfigMock( [ 'cache' => $cache ] ); @@ -95,11 +116,8 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'class' => HashBagOStuff::class ] ] ); $config->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ - [ 'known' => 'from-fetch' ], - null, // error - false // retry? - ] ); + ->willReturn( self::createEtcdResponse( + [ 'config' => [ 'known' => 'from-fetch' ], ] ) ); $this->assertSame( 'from-fetch', $config->get( 'known' ) ); } @@ -166,7 +184,8 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'cache' => $cache, ] ); $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] ); + ->willReturn( + self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); } @@ -191,7 +210,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'cache' => $cache, ] ); $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ null, 'Fake error', false ] ); + ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) ); $this->setExpectedException( ConfigException::class ); $mock->get( 'key' ); @@ -213,6 +232,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { [ 'config' => [ 'known' => 'from-cache' ], 'expires' => INF, + 'modifiedIndex' => 123 ] ) ); // .. misses lock @@ -241,6 +261,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ->willReturn( [ 'config' => [ 'known' => 'from-cache' ], 'expires' => INF, + 'modifiedIndex' => 0, ] ); $cache->expects( $this->never() )->method( 'lock' ); @@ -266,6 +287,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ->willReturn( [ 'config' => [ 'known' => 'from-cache' ], 'expires' => INF, + 'modifiedIndex' => 0, ] ); $cache->expects( $this->never() )->method( 'lock' ); @@ -292,6 +314,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { [ 'config' => [ 'known' => 'from-cache-expired' ], 'expires' => -INF, + 'modifiedIndex' => 0, ] ); // .. gets lock @@ -303,7 +326,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'cache' => $cache, ] ); $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] ); + ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); } @@ -321,6 +344,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { [ 'config' => [ 'known' => 'from-cache-expired' ], 'expires' => -INF, + 'modifiedIndex' => 0, ] ); // .. gets lock @@ -332,7 +356,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'cache' => $cache, ] ); $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( [ null, 'Fake failure', true ] ); + ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) ); $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) ); } @@ -350,6 +374,7 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ->willReturn( [ 'config' => [ 'known' => 'from-cache-expired' ], 'expires' => -INF, + 'modifiedIndex' => 0, ] ); // .. misses lock $cache->expects( $this->once() )->method( 'lock' ) @@ -374,16 +399,16 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => json_encode( [ 'node' => [ 'nodes' => [ [ 'key' => '/example/foo', - 'value' => json_encode( [ 'val' => true ] ) + 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123 ], ] ] ] ), 'error' => '', ], - 'expect' => [ - [ 'foo' => true ], // data - null, - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'foo' => true ], // data + 'modifiedIndex' => 123 + ] ), ], '200 OK - Empty dir' => [ 'http' => [ @@ -393,25 +418,27 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => json_encode( [ 'node' => [ 'nodes' => [ [ 'key' => '/example/foo', - 'value' => json_encode( [ 'val' => true ] ) + 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123 ], [ 'key' => '/example/sub', 'dir' => true, + 'modifiedIndex' => 234, 'nodes' => [], ], [ 'key' => '/example/bar', - 'value' => json_encode( [ 'val' => false ] ) + 'value' => json_encode( [ 'val' => false ] ), + 'modifiedIndex' => 125 ], ] ] ] ), 'error' => '', ], - 'expect' => [ - [ 'foo' => true, 'bar' => false ], // data - null, - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'foo' => true, 'bar' => false ], // data + 'modifiedIndex' => 125 // largest modified index + ] ), ], '200 OK - Recursive' => [ 'http' => [ @@ -422,25 +449,28 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { [ 'key' => '/example/a', 'dir' => true, + 'modifiedIndex' => 124, 'nodes' => [ [ 'key' => 'b', 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123, + ], [ 'key' => 'c', 'value' => json_encode( [ 'val' => false ] ), + 'modifiedIndex' => 123, ], ], ], ] ] ] ), 'error' => '', ], - 'expect' => [ - [ 'a/b' => true, 'a/c' => false ], // data - null, - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'a/b' => true, 'a/c' => false ], // data + 'modifiedIndex' => 123 // largest modified index + ] ), ], '200 OK - Missing nodes at second level' => [ 'http' => [ @@ -451,15 +481,14 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { [ 'key' => '/example/a', 'dir' => true, + 'modifiedIndex' => 0, ], ] ] ] ), 'error' => '', ], - 'expect' => [ - null, - "Unexpected JSON response in dir 'a'; missing 'nodes' list.", - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.", + ] ), ], '200 OK - Directory with non-array "nodes" key' => [ 'http' => [ @@ -475,11 +504,9 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { ] ] ] ), 'error' => '', ], - 'expect' => [ - null, - "Unexpected JSON response in dir 'a'; 'nodes' is not an array.", - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.", + ] ), ], '200 OK - Correctly encoded garbage response' => [ 'http' => [ @@ -489,11 +516,9 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => json_encode( [ 'foo' => 'bar' ] ), 'error' => '', ], - 'expect' => [ - null, - "Unexpected JSON response: Missing or invalid node at top level.", - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response: Missing or invalid node at top level.", + ] ), ], '200 OK - Bad value' => [ 'http' => [ @@ -503,30 +528,27 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => json_encode( [ 'node' => [ 'nodes' => [ [ 'key' => '/example/foo', - 'value' => ';"broken{value' + 'value' => ';"broken{value', + 'modifiedIndex' => 123, ] ] ] ] ), 'error' => '', ], - 'expect' => [ - null, // data - "Failed to parse value for 'foo'.", - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Failed to parse value for 'foo'.", + ] ), ], '200 OK - Empty node list' => [ 'http' => [ 'code' => 200, 'reason' => 'OK', 'headers' => [], - 'body' => '{"node":{"nodes":[]}}', + 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}', 'error' => '', ], - 'expect' => [ - [], // data - null, - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'config' => [], // data + ] ), ], '200 OK - Invalid JSON' => [ 'http' => [ @@ -536,11 +558,9 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => '', 'error' => '(curl error: no status set)', ], - 'expect' => [ - null, // data - "Error unserializing JSON response.", - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Error unserializing JSON response.", + ] ), ], '404 Not Found' => [ 'http' => [ @@ -550,11 +570,9 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => '', 'error' => '', ], - 'expect' => [ - null, // data - 'HTTP 404 (Not Found)', - false // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => 'HTTP 404 (Not Found)', + ] ), ], '400 Bad Request - custom error' => [ 'http' => [ @@ -564,11 +582,10 @@ class EtcdConfigTest extends PHPUnit\Framework\TestCase { 'body' => '', 'error' => 'No good reason', ], - 'expect' => [ - null, // data - 'No good reason', - true // retry - ], + 'expect' => self::createEtcdResponse( [ + 'error' => 'No good reason', + 'retry' => true, // retry + ] ), ], ]; } diff --git a/tests/phpunit/includes/content/JavaScriptContentTest.php b/tests/phpunit/includes/content/JavaScriptContentTest.php index 6656fa4d76..823be6f795 100644 --- a/tests/phpunit/includes/content/JavaScriptContentTest.php +++ b/tests/phpunit/includes/content/JavaScriptContentTest.php @@ -153,16 +153,6 @@ class JavaScriptContentTest extends TextContentTest { 'any', true ], - [ 'Foo', - null, - 'comma', - false - ], - [ 'Foo, bar', - null, - 'comma', - false - ], [ 'Foo', null, 'link', @@ -188,11 +178,6 @@ class JavaScriptContentTest extends TextContentTest { 'any', true ], - [ '#REDIRECT [[bar]]', - true, - 'comma', - false - ], [ '#REDIRECT [[bar]]', true, 'link', diff --git a/tests/phpunit/includes/content/TextContentTest.php b/tests/phpunit/includes/content/TextContentTest.php index b5480911df..406bc96b95 100644 --- a/tests/phpunit/includes/content/TextContentTest.php +++ b/tests/phpunit/includes/content/TextContentTest.php @@ -197,16 +197,6 @@ class TextContentTest extends MediaWikiLangTestCase { 'any', true ], - [ 'Foo', - null, - 'comma', - false - ], - [ 'Foo, bar', - null, - 'comma', - false - ], ]; } diff --git a/tests/phpunit/includes/content/WikitextContentTest.php b/tests/phpunit/includes/content/WikitextContentTest.php index e04f562429..1db6aab618 100644 --- a/tests/phpunit/includes/content/WikitextContentTest.php +++ b/tests/phpunit/includes/content/WikitextContentTest.php @@ -266,16 +266,6 @@ just a test" 'any', true ], - [ 'Foo', - null, - 'comma', - false - ], - [ 'Foo, bar', - null, - 'comma', - true - ], [ 'Foo', null, 'link', @@ -301,11 +291,6 @@ just a test" 'any', false ], - [ '#REDIRECT [[bar]]', - true, - 'comma', - false - ], [ '#REDIRECT [[bar]]', true, 'link', diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php index 2de35a7c88..729b58c7ad 100644 --- a/tests/phpunit/includes/db/DatabaseSqliteTest.php +++ b/tests/phpunit/includes/db/DatabaseSqliteTest.php @@ -508,4 +508,12 @@ class DatabaseSqliteTest extends MediaWikiTestCase { $this->assertContains( 'SQLite ', $toString ); } + + /** + * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes() + */ + public function testsAttributes() { + $attributes = Database::attributesFromType( 'sqlite' ); + $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ); + } } diff --git a/tests/phpunit/includes/db/LoadBalancerTest.php b/tests/phpunit/includes/db/LoadBalancerTest.php index fe7b710a1f..6cc41d1f41 100644 --- a/tests/phpunit/includes/db/LoadBalancerTest.php +++ b/tests/phpunit/includes/db/LoadBalancerTest.php @@ -1,10 +1,5 @@ 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'sqlite', + 'dbDirectory' => "some_directory", + 'load' => 0 + ] + ]; + + $lb = new LoadBalancer( [ + 'servers' => $servers, + 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ), + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + + $this->assertTrue( $lb->getServerAttributes( 0 )[Database::ATTR_DB_LEVEL_LOCKING] ); + + $servers = [ + [ // master + 'host' => 'db1001', + 'user' => 'wikiuser', + 'password' => 'none', + 'dbname' => 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'mysql', + 'load' => 100 + ], + [ // emulated replica + 'host' => 'db1002', + 'user' => 'wikiuser', + 'password' => 'none', + 'dbname' => 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'mysql', + 'load' => 100 + ] + ]; + + $lb = new LoadBalancer( [ + 'servers' => $servers, + 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ), + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + + $this->assertFalse( $lb->getServerAttributes( 1 )[Database::ATTR_DB_LEVEL_LOCKING] ); + } } diff --git a/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php b/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php new file mode 100644 index 0000000000..83e9a47ca6 --- /dev/null +++ b/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php @@ -0,0 +1,77 @@ + 1, 'users' => 2 ] ); + $update2 = SiteStatsUpdate::factory( [ 'users' => 1, 'images' => 1 ] ); + + $update1->merge( $update2 ); + $wrapped = TestingAccessWrapper::newFromObject( $update1 ); + + $this->assertEquals( 1, $wrapped->pages ); + $this->assertEquals( 3, $wrapped->users ); + $this->assertEquals( 1, $wrapped->images ); + $this->assertEquals( 0, $wrapped->edits ); + $this->assertEquals( 0, $wrapped->articles ); + } + + /** + * @covers SiteStatsUpdate::doUpdate() + * @covers SiteStatsInit::refresh() + */ + public function testDoUpdate() { + $this->setMwGlobals( 'wgSiteStatsAsyncFactor', false ); + $this->setMwGlobals( 'wgCommandLineMode', false ); // disable opportunistic updates + + $dbw = wfGetDB( DB_MASTER ); + $statsInit = new SiteStatsInit( $dbw ); + $statsInit->refresh(); + + $ei = SiteStats::edits(); // trigger load + $pi = SiteStats::pages(); + $ui = SiteStats::users(); + $fi = SiteStats::images(); + $ai = SiteStats::articles(); + + $dbw->begin( __METHOD__ ); // block opportunistic updates + + $update = SiteStatsUpdate::factory( [ 'pages' => 2, 'images' => 1, 'edits' => 2 ] ); + $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() ); + $update->doUpdate(); + $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() ); + + // Still the same + SiteStats::unload(); + $this->assertEquals( $pi, SiteStats::pages(), 'page count' ); + $this->assertEquals( $ei, SiteStats::edits(), 'edit count' ); + $this->assertEquals( $ui, SiteStats::users(), 'user count' ); + $this->assertEquals( $fi, SiteStats::images(), 'file count' ); + $this->assertEquals( $ai, SiteStats::articles(), 'article count' ); + $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() ); + + $dbw->commit( __METHOD__ ); + + $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() ); + DeferredUpdates::doUpdates(); + $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() ); + + SiteStats::unload(); + $this->assertEquals( $pi + 2, SiteStats::pages(), 'page count' ); + $this->assertEquals( $ei + 2, SiteStats::edits(), 'edit count' ); + $this->assertEquals( $ui, SiteStats::users(), 'user count' ); + $this->assertEquals( $fi + 1, SiteStats::images(), 'file count' ); + $this->assertEquals( $ai, SiteStats::articles(), 'article count' ); + + $statsInit = new SiteStatsInit(); + $statsInit->refresh(); + } +} diff --git a/tests/phpunit/includes/import/ImportTest.php b/tests/phpunit/includes/import/ImportTest.php index 7bb03db87e..3b91f5b3d5 100644 --- a/tests/phpunit/includes/import/ImportTest.php +++ b/tests/phpunit/includes/import/ImportTest.php @@ -290,12 +290,15 @@ EOF $importer->doImport(); $db = wfGetDB( DB_MASTER ); + $revQuery = Revision::getQueryInfo(); $row = $db->selectRow( - 'revision', - [ 'rev_user', 'rev_user_text' ], + $revQuery['tables'], + $revQuery['fields'], [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0000" ) ], - __METHOD__ + __METHOD__, + [], + $revQuery['joins'] ); $this->assertSame( $assign && $create ? 'UserDoesNotExist' : 'Xxx>UserDoesNotExist', @@ -304,10 +307,12 @@ EOF $this->assertSame( $assign && $create ? $hookId : 0, (int)$row->rev_user ); $row = $db->selectRow( - 'revision', - [ 'rev_user', 'rev_user_text' ], + $revQuery['tables'], + $revQuery['fields'], [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0001" ) ], - __METHOD__ + __METHOD__, + [], + $revQuery['joins'] ); $this->assertSame( ( $assign ? '' : 'Xxx>' ) . $user->getName(), $row->rev_user_text ); $this->assertSame( $assign ? $user->getId() : 0, (int)$row->rev_user ); diff --git a/tests/phpunit/includes/libs/CSSMinTest.php b/tests/phpunit/includes/libs/CSSMinTest.php index 89cf68fcd4..667eb0ace6 100644 --- a/tests/phpunit/includes/libs/CSSMinTest.php +++ b/tests/phpunit/includes/libs/CSSMinTest.php @@ -149,6 +149,12 @@ class CSSMinTest extends MediaWikiTestCase { [ "foo { content: '\"'; }", "foo{content:'\"'}" ], // - Whitespace in string values [ 'foo { content: " "; }', 'foo{content:" "}' ], + + // Whitespaces after opening and before closing parentheses and brackets + [ 'a:not( [ href ] ) { prop: url( foobar.png ); }', 'a:not([href]){prop:url(foobar.png)}' ], + + // Ensure that the invalid "url (" will not become the valid "url(" by minification + [ 'foo { prop: url ( foobar.png ); }', 'foo{prop:url (foobar.png)}' ], ]; } diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php index b9f57b568b..14c705734b 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php @@ -29,6 +29,7 @@ use Wikimedia\Rdbms\MySQLMasterPos; use Wikimedia\Rdbms\DatabaseMysqlBase; use Wikimedia\Rdbms\DatabaseMysqli; use Wikimedia\Rdbms\Database; +use Wikimedia\TestingAccessWrapper; /** * Fake class around abstract class so we can call concrete methods. @@ -495,4 +496,112 @@ class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { $db->clearFlag( Database::DBO_IGNORE ); } + + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testSerialize() { + $pos = new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + + $pos = new MySQLMasterPos( '255-11-23', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::isInsertSelectSafe + * @dataProvider provideInsertSelectCases + */ + public function testInsertSelectIsSafe( $insertOpts, $selectOpts, $row, $safe ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getReplicationSafetyInfo' ] ) + ->getMock(); + $db->method( 'getReplicationSafetyInfo' )->willReturn( (object)$row ); + $dbw = TestingAccessWrapper::newFromObject( $db ); + + $this->assertEquals( $safe, $dbw->isInsertSelectSafe( $insertOpts, $selectOpts ) ); + } + + public function provideInsertSelectCases() { + return [ + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 2, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + + ]; + } } diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php index 184d6260f8..5c1943b128 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -64,6 +64,44 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { "FROM table " . "WHERE alias = 'text'" ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => 'alias = \'text\'', + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [], + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '', + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '0', // T188314 + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE 0" + ], [ [ // 'tables' with space prepended indicates pre-escaped table name @@ -457,7 +495,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [], isset( $sql['selectJoinConds'] ) ? $sql['selectJoinConds'] : [] ); - $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, $sqlInsert ] ), $dbWeb ); + $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, 'BEGIN', $sqlInsert, 'COMMIT' ] ), $dbWeb ); } public static function provideInsertSelect() { @@ -518,6 +556,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'srcTable' => [ 'select_table1', 'select_table2' ], 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], 'conds' => [ 'field' => 2 ], + 'insertOptions' => [ 'NO_AUTO_COLUMNS' ], 'selectOptions' => [ 'ORDER BY' => 'field', 'FORCE INDEX' => [ 'select_table1' => 'index1' ] ], 'selectJoinConds' => [ 'select_table2' => [ 'LEFT JOIN', [ 'select_table1.foo = select_table2.bar' ] ], @@ -537,6 +576,30 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { ]; } + public function testInsertSelectBatching() { + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $rows = []; + for ( $i = 0; $i <= 25000; $i++ ) { + $rows[] = [ 'field' => $i ]; + } + $dbWeb->forceNextResult( $rows ); + $dbWeb->insertSelect( + 'insert_table', + 'select_table', + [ 'field' => 'field2' ], + '*', + __METHOD__ + ); + $this->assertLastSqlDb( implode( '; ', [ + 'SELECT field2 AS field FROM select_table WHERE * FOR UPDATE', + 'BEGIN', + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 0, 9999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 10000, 19999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 20000, 25000 ) ) . "')", + 'COMMIT' + ] ), $dbWeb ); + } + /** * @dataProvider provideReplace * @covers Wikimedia\Rdbms\Database::replace @@ -559,11 +622,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'uniqueIndexes' => [ 'field' ], 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], ], - "DELETE FROM replace_table " . + "BEGIN; DELETE FROM replace_table " . "WHERE (field = 'text'); " . "INSERT INTO replace_table " . "(field,field2) " . - "VALUES ('text','text2')" + "VALUES ('text','text2'); COMMIT" ], [ [ @@ -575,11 +638,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'md_deps' => 'deps', ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module' AND md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module','skin','deps')" + "VALUES ('module','skin','deps'); COMMIT" ], [ [ @@ -597,7 +660,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { ], ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module' AND md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . @@ -606,7 +669,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { "WHERE (md_module = 'module2' AND md_skin = 'skin2'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module2','skin2','deps2')" + "VALUES ('module2','skin2','deps2'); COMMIT" ], [ [ @@ -624,7 +687,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { ], ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module') OR (md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . @@ -633,7 +696,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { "WHERE (md_module = 'module2') OR (md_skin = 'skin2'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module2','skin2','deps2')" + "VALUES ('module2','skin2','deps2'); COMMIT" ], [ [ @@ -645,9 +708,9 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'md_deps' => 'deps', ], ], - "INSERT INTO module_deps " . + "BEGIN; INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module','skin','deps')" + "VALUES ('module','skin','deps'); COMMIT" ], ]; } diff --git a/tests/phpunit/includes/logging/DatabaseLogEntryTest.php b/tests/phpunit/includes/logging/DatabaseLogEntryTest.php new file mode 100644 index 0000000000..4af1742e1a --- /dev/null +++ b/tests/phpunit/includes/logging/DatabaseLogEntryTest.php @@ -0,0 +1,162 @@ +resetServiceForTesting( 'CommentStore' ); + MediaWikiServices::getInstance()->resetServiceForTesting( 'ActorMigration' ); + } + + public function tearDown() { + parent::tearDown(); + + MediaWikiServices::getInstance()->resetServiceForTesting( 'CommentStore' ); + MediaWikiServices::getInstance()->resetServiceForTesting( 'ActorMigration' ); + } + + /** + * @covers DatabaseLogEntry::newFromId + * @covers DatabaseLogEntry::getSelectQueryData + * + * @dataProvider provideNewFromId + * + * @param int $id + * @param array $selectFields + * @param string[]|null $row + * @param string[]|null $expectedFields + * @param string $migration + */ + public function testNewFromId( $id, + array $selectFields, + array $row = null, + array $expectedFields = null, + $migration + ) { + $this->setMwGlobals( [ + 'wgCommentTableSchemaMigrationStage' => $migration, + 'wgActorTableSchemaMigrationStage' => $migration, + ] ); + + $row = $row ? (object)$row : null; + $db = $this->getMock( IDatabase::class ); + $db->expects( self::once() ) + ->method( 'selectRow' ) + ->with( $selectFields['tables'], + $selectFields['fields'], + $selectFields['conds'], + 'DatabaseLogEntry::newFromId', + $selectFields['options'], + $selectFields['join_conds'] + ) + ->will( self::returnValue( $row ) ); + + /** @var IDatabase $db */ + $logEntry = DatabaseLogEntry::newFromId( $id, $db ); + + if ( !$expectedFields ) { + self::assertNull( $logEntry, "Expected no log entry returned for id=$id" ); + } else { + self::assertEquals( $id, $logEntry->getId() ); + self::assertEquals( $expectedFields['type'], $logEntry->getType() ); + self::assertEquals( $expectedFields['comment'], $logEntry->getComment() ); + } + } + + public function provideNewFromId() { + $oldTables = [ + 'tables' => [ 'logging', 'user' ], + 'fields' => [ + 'log_id', + 'log_type', + 'log_action', + 'log_timestamp', + 'log_namespace', + 'log_title', + 'log_params', + 'log_deleted', + 'user_id', + 'user_name', + 'user_editcount', + 'log_comment_text' => 'log_comment', + 'log_comment_data' => 'NULL', + 'log_comment_cid' => 'NULL', + 'log_user' => 'log_user', + 'log_user_text' => 'log_user_text', + 'log_actor' => 'NULL', + ], + 'options' => [], + 'join_conds' => [ 'user' => [ 'LEFT JOIN', 'user_id=log_user' ] ], + ]; + $newTables = [ + 'tables' => [ + 'logging', + 'user', + 'comment_log_comment' => 'comment', + 'actor_log_user' => 'actor' + ], + 'fields' => [ + 'log_id', + 'log_type', + 'log_action', + 'log_timestamp', + 'log_namespace', + 'log_title', + 'log_params', + 'log_deleted', + 'user_id', + 'user_name', + 'user_editcount', + 'log_comment_text' => 'comment_log_comment.comment_text', + 'log_comment_data' => 'comment_log_comment.comment_data', + 'log_comment_cid' => 'comment_log_comment.comment_id', + 'log_user' => 'actor_log_user.actor_user', + 'log_user_text' => 'actor_log_user.actor_name', + 'log_actor' => 'log_actor', + ], + 'options' => [], + 'join_conds' => [ + 'user' => [ 'LEFT JOIN', 'user_id=actor_log_user.actor_user' ], + 'comment_log_comment' => [ 'JOIN', 'comment_log_comment.comment_id = log_comment_id' ], + 'actor_log_user' => [ 'JOIN', 'actor_log_user.actor_id = log_actor' ], + ], + ]; + return [ + [ + 0, + $oldTables + [ 'conds' => [ 'log_id' => 0 ] ], + null, + null, + MIGRATION_OLD, + ], + [ + 123, + $oldTables + [ 'conds' => [ 'log_id' => 123 ] ], + [ + 'log_id' => 123, + 'log_type' => 'foobarize', + 'log_comment_text' => 'test!', + 'log_comment_data' => null, + ], + [ 'type' => 'foobarize', 'comment' => 'test!' ], + MIGRATION_OLD, + ], + [ + 567, + $newTables + [ 'conds' => [ 'log_id' => 567 ] ], + [ + 'log_id' => 567, + 'log_type' => 'foobarize', + 'log_comment_text' => 'test!', + 'log_comment_data' => null, + ], + [ 'type' => 'foobarize', 'comment' => 'test!' ], + MIGRATION_NEW, + ], + ]; + } +} diff --git a/tests/phpunit/includes/logging/LogFormatterTestCase.php b/tests/phpunit/includes/logging/LogFormatterTestCase.php index 2dc9a2cf4f..786d761900 100644 --- a/tests/phpunit/includes/logging/LogFormatterTestCase.php +++ b/tests/phpunit/includes/logging/LogFormatterTestCase.php @@ -36,6 +36,7 @@ abstract class LogFormatterTestCase extends MediaWikiLangTestCase { 'log_timestamp' => isset( $data['timestamp'] ) ? $data['timestamp'] : wfTimestampNow(), 'log_user' => isset( $data['user'] ) ? $data['user'] : 0, 'log_user_text' => isset( $data['user_text'] ) ? $data['user_text'] : 'User', + 'log_actor' => isset( $data['actor'] ) ? $data['actor'] : 0, 'log_namespace' => isset( $data['namespace'] ) ? $data['namespace'] : NS_MAIN, 'log_title' => isset( $data['title'] ) ? $data['title'] : 'Main_Page', 'log_page' => isset( $data['page'] ) ? $data['page'] : 0, diff --git a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php index 0991254119..c943cef906 100644 --- a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php +++ b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php @@ -108,4 +108,21 @@ class JpegMetadataExtractorTest extends MediaWikiTestCase { $expected = 'BE'; $this->assertEquals( $expected, $res['byteOrder'] ); } + + public function testInfiniteRead() { + // test file truncated right after a segment, which previously + // caused an infinite loop looking for the next segment byte. + // Should get past infinite loop and throw in wfUnpack() + $this->setExpectedException( 'MWException' ); + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop1.jpg' ); + } + + public function testInfiniteRead2() { + // test file truncated after a segment's marker and size, which + // would cause a seek past end of file. Seek past end of file + // doesn't actually fail, but prevents further reading and was + // devolving into the previous case (testInfiniteRead). + $this->setExpectedException( 'MWException' ); + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop2.jpg' ); + } } diff --git a/tests/phpunit/includes/page/WikiPageDbTestBase.php b/tests/phpunit/includes/page/WikiPageDbTestBase.php index 6b680e56d6..6367a0f8ef 100644 --- a/tests/phpunit/includes/page/WikiPageDbTestBase.php +++ b/tests/phpunit/includes/page/WikiPageDbTestBase.php @@ -374,20 +374,6 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase { true ], - // comma - [ 'WikiPageTest_testIsCountable', - CONTENT_MODEL_WIKITEXT, - 'Foo', - 'comma', - false - ], - [ 'WikiPageTest_testIsCountable', - CONTENT_MODEL_WIKITEXT, - 'Foo, bar', - 'comma', - true - ], - // link [ 'WikiPageTest_testIsCountable', CONTENT_MODEL_WIKITEXT, @@ -409,12 +395,6 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase { 'any', false ], - [ 'WikiPageTest_testIsCountable', - CONTENT_MODEL_WIKITEXT, - '#REDIRECT [[bar]]', - 'comma', - false - ], [ 'WikiPageTest_testIsCountable', CONTENT_MODEL_WIKITEXT, '#REDIRECT [[bar]]', @@ -429,12 +409,6 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase { 'any', false ], - [ 'Talk:WikiPageTest_testIsCountable', - CONTENT_MODEL_WIKITEXT, - 'Foo, bar', - 'comma', - false - ], [ 'Talk:WikiPageTest_testIsCountable', CONTENT_MODEL_WIKITEXT, 'Foo [[bar]]', @@ -449,12 +423,6 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase { 'any', false ], - [ 'MediaWiki:WikiPageTest_testIsCountable.js', - null, - 'Foo, bar', - 'comma', - false - ], [ 'MediaWiki:WikiPageTest_testIsCountable.js', null, 'Foo [[bar]]', @@ -1784,12 +1752,13 @@ more stuff // Make sure the log entry looks good // log_params is not checked here + $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); $this->assertSelect( - 'logging', + [ 'logging' ] + $actorQuery['tables'], [ 'log_comment', - 'log_user', - 'log_user_text', + 'log_user' => $actorQuery['fields']['log_user'], + 'log_user_text' => $actorQuery['fields']['log_user_text'], 'log_namespace', 'log_title', ], @@ -1800,7 +1769,9 @@ more stuff $user->getName(), (string)$page->getTitle()->getNamespace(), $page->getTitle()->getDBkey(), - ] ] + ] ], + [], + $actorQuery['joins'] ); } diff --git a/tests/phpunit/includes/parser/StripStateTest.php b/tests/phpunit/includes/parser/StripStateTest.php new file mode 100644 index 0000000000..0f4f6e0fae --- /dev/null +++ b/tests/phpunit/includes/parser/StripStateTest.php @@ -0,0 +1,136 @@ +setContentLang( 'qqx' ); + } + + private function getMarker() { + static $i; + return Parser::MARKER_PREFIX . '-blah-' . sprintf( '%08X', $i++ ) . Parser::MARKER_SUFFIX; + } + + private static function getWarning( $message, $max = '' ) { + return "($message: $max)"; + } + + public function testAddNoWiki() { + $ss = new StripState; + $marker = $this->getMarker(); + $ss->addNoWiki( $marker, '<>' ); + $text = "x{$marker}y"; + $text = $ss->unstripGeneral( $text ); + $text = str_replace( '<', '', $text ); + $text = $ss->unstripNoWiki( $text ); + $this->assertSame( 'x<>y', $text ); + } + + public function testAddGeneral() { + $ss = new StripState; + $marker = $this->getMarker(); + $ss->addGeneral( $marker, '<>' ); + $text = "x{$marker}y"; + $text = $ss->unstripNoWiki( $text ); + $text = str_replace( '<', '', $text ); + $text = $ss->unstripGeneral( $text ); + $this->assertSame( 'x<>y', $text ); + } + + public function testUnstripBoth() { + $ss = new StripState; + $mk1 = $this->getMarker(); + $mk2 = $this->getMarker(); + $ss->addNoWiki( $mk1, '<1>' ); + $ss->addGeneral( $mk2, '<2>' ); + $text = "x{$mk1}{$mk2}y"; + $text = str_replace( '<', '', $text ); + $text = $ss->unstripBoth( $text ); + $this->assertSame( 'x<1><2>y', $text ); + } + + public static function provideUnstripRecursive() { + return [ + [ 0, 'text' ], + [ 1, '=text=' ], + [ 2, '==text==' ], + [ 3, '==' . self::getWarning( 'unstrip-depth-warning', 2 ) . '==' ], + ]; + } + + /** @dataProvider provideUnstripRecursive */ + public function testUnstripRecursive( $depth, $expected ) { + $ss = new StripState( null, [ 'depthLimit' => 2 ] ); + $text = 'text'; + for ( $i = 0; $i < $depth; $i++ ) { + $mk = $this->getMarker(); + $ss->addNoWiki( $mk, "={$text}=" ); + $text = $mk; + } + $text = $ss->unstripNoWiki( $text ); + $this->assertSame( $expected, $text ); + } + + public function testUnstripLoop() { + $ss = new StripState( null, [ 'depthLimit' => 2 ] ); + $mk = $this->getMarker(); + $ss->addNoWiki( $mk, $mk ); + $text = $ss->unstripNoWiki( $mk ); + $this->assertSame( self::getWarning( 'parser-unstrip-loop-warning' ), $text ); + } + + public static function provideUnstripSize() { + return [ + [ 0, 'x' ], + [ 1, 'xx' ], + [ 2, str_repeat( self::getWarning( 'unstrip-size-warning', 5 ), 2 ) ] + ]; + } + + /** @dataProvider provideUnstripSize */ + public function testUnstripSize( $depth, $expected ) { + $ss = new StripState( null, [ 'sizeLimit' => 5 ] ); + $text = 'x'; + for ( $i = 0; $i < $depth; $i++ ) { + $mk = $this->getMarker(); + $ss->addNoWiki( $mk, $text ); + $text = "$mk$mk"; + } + $text = $ss->unstripNoWiki( $text ); + $this->assertSame( $expected, $text ); + } + + public function provideGetLimitReport() { + for ( $i = 1; $i < 4; $i++ ) { + yield [ $i ]; + } + } + + /** @dataProvider provideGetLimitReport */ + public function testGetLimitReport( $depth ) { + $sizeLimit = 100000; + $ss = new StripState( null, [ 'depthLimit' => 5, 'sizeLimit' => $sizeLimit ] ); + $text = 'x'; + for ( $i = 0; $i < $depth; $i++ ) { + $mk = $this->getMarker(); + $ss->addNoWiki( $mk, $text ); + $text = "$mk$mk"; + } + $text = $ss->unstripNoWiki( $text ); + $report = $ss->getLimitReport(); + $messages = []; + foreach ( $report as list( $msg, $params ) ) { + $messages[$msg] = $params; + } + $this->assertSame( [ $depth - 1, 5 ], $messages['limitreport-unstrip-depth'] ); + $this->assertSame( + [ + strlen( $this->getMarker() ) * 2 * ( pow( 2, $depth ) - 2 ) + pow( 2, $depth ), + $sizeLimit + ], + $messages['limitreport-unstrip-size' ] ); + } +} diff --git a/tests/phpunit/includes/password/UserPasswordPolicyTest.php b/tests/phpunit/includes/password/UserPasswordPolicyTest.php index 0839cfbbf1..78175fac8f 100644 --- a/tests/phpunit/includes/password/UserPasswordPolicyTest.php +++ b/tests/phpunit/includes/password/UserPasswordPolicyTest.php @@ -26,6 +26,8 @@ */ class UserPasswordPolicyTest extends MediaWikiTestCase { + protected $tablesUsed = [ 'user', 'user_groups' ]; + protected $policies = [ 'checkuser' => [ 'MinimalPasswordLength' => 10, diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php index 7bfd7698ea..a75ea56879 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -127,6 +127,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { ] ); $client->setModuleScripts( [ 'test.scripts', + 'test.scripts.user', 'test.scripts.user.empty', 'test.scripts.shouldembed', 'test.unregistered.scripts', @@ -142,6 +143,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { 'test.styles.private' => 'ready', 'test.styles.shouldembed' => 'ready', 'test.scripts' => 'loading', + 'test.scripts.user' => 'loading', 'test.scripts.user.empty' => 'ready', 'test.scripts.shouldembed' => 'loading', ], @@ -153,6 +155,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { ], 'scripts' => [ 'test.scripts', + 'test.scripts.user', 'test.scripts.shouldembed', ], 'embed' => [ diff --git a/tests/phpunit/includes/shell/CommandTest.php b/tests/phpunit/includes/shell/CommandTest.php index 3862cc2444..2e03163885 100644 --- a/tests/phpunit/includes/shell/CommandTest.php +++ b/tests/phpunit/includes/shell/CommandTest.php @@ -170,5 +170,12 @@ class CommandTest extends PHPUnit\Framework\TestCase { $command->input( str_repeat( '!', 1000000 ) ); $result = $command->execute(); $this->assertSame( 1000000, strlen( $result->getStdout() ) ); + + // And try it with empty input + $command = new Command(); + $command->params( 'cat' ); + $command->input( '' ); + $result = $command->execute(); + $this->assertSame( '', $result->getStdout() ); } } diff --git a/tests/phpunit/includes/shell/FirejailCommandTest.php b/tests/phpunit/includes/shell/FirejailCommandTest.php index 199393c692..681c3dcda0 100644 --- a/tests/phpunit/includes/shell/FirejailCommandTest.php +++ b/tests/phpunit/includes/shell/FirejailCommandTest.php @@ -34,7 +34,7 @@ class FirejailCommandTest extends PHPUnit\Framework\TestCase { $limit = "/bin/bash '$IP/includes/shell/limit.sh'"; $profile = "--profile=$IP/includes/shell/firejail.profile"; $blacklist = '--blacklist=' . realpath( MW_CONFIG_FILE ); - $default = "$blacklist --noroot --seccomp=@default --private-dev"; + $default = "$blacklist --noroot --seccomp --private-dev"; return [ [ 'No restrictions', @@ -58,12 +58,12 @@ class FirejailCommandTest extends PHPUnit\Framework\TestCase { [ 'seccomp', 'ls', Shell::SECCOMP, - "$limit 'firejail --quiet $profile --seccomp=@default -- '\''ls'\''' $env" + "$limit 'firejail --quiet $profile --seccomp -- '\''ls'\''' $env" ], [ 'seccomp & no execve', 'ls', Shell::SECCOMP | Shell::NO_EXECVE, - "$limit 'firejail --quiet $profile --shell=none --seccomp=@default,execve -- '\''ls'\''' $env" + "$limit 'firejail --quiet $profile --shell=none --seccomp=execve -- '\''ls'\''' $env" ], ]; } diff --git a/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php b/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php index 9b81d6d77d..aac25d8bbc 100644 --- a/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php +++ b/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php @@ -199,10 +199,15 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testRcHidemyselfFilter() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $user = $this->getTestUser()->getUser(); + $user->getActorId( wfGetDB( DB_MASTER ) ); $this->assertConditions( [ # expected - "rc_user_text != '{$user->getName()}'", + "NOT((rc_actor = '{$user->getActorId()}') OR " + . "(rc_actor = '0' AND rc_user = '{$user->getId()}'))", ], [ 'hidemyself' => 1, @@ -212,9 +217,10 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase ); $user = User::newFromName( '10.11.12.13', false ); + $id = $user->getActorId( wfGetDB( DB_MASTER ) ); $this->assertConditions( [ # expected - "rc_user_text != '10.11.12.13'", + "NOT((rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13'))", ], [ 'hidemyself' => 1, @@ -225,10 +231,15 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testRcHidebyothersFilter() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $user = $this->getTestUser()->getUser(); + $user->getActorId( wfGetDB( DB_MASTER ) ); $this->assertConditions( [ # expected - "rc_user_text = '{$user->getName()}'", + "(rc_actor = '{$user->getActorId()}') OR " + . "(rc_actor = '0' AND rc_user_text = '{$user->getName()}')", ], [ 'hidebyothers' => 1, @@ -238,9 +249,10 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase ); $user = User::newFromName( '10.11.12.13', false ); + $id = $user->getActorId( wfGetDB( DB_MASTER ) ); $this->assertConditions( [ # expected - "rc_user_text = '10.11.12.13'", + "(rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13')", ], [ 'hidebyothers' => 1, @@ -428,10 +440,13 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testFilterUserExpLevelAllExperienceLevels() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $this->assertConditions( [ # expected - 'rc_user != 0', + 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0', ], [ 'userExpLevel' => 'newcomer;learner;experienced', @@ -441,10 +456,13 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testFilterUserExpLevelRegistrered() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $this->assertConditions( [ # expected - 'rc_user != 0', + 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0', ], [ 'userExpLevel' => 'registered', @@ -454,10 +472,13 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testFilterUserExpLevelUnregistrered() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $this->assertConditions( [ # expected - 'rc_user' => 0, + 'COALESCE( actor_rc_user.actor_user, rc_user ) = 0', ], [ 'userExpLevel' => 'unregistered', @@ -467,10 +488,13 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testFilterUserExpLevelRegistreredOrLearner() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $this->assertConditions( [ # expected - 'rc_user != 0', + 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0', ], [ 'userExpLevel' => 'registered;learner', @@ -480,10 +504,14 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase } public function testFilterUserExpLevelUnregistreredOrExperienced() { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH ); + $this->overrideMwServices(); + $conds = $this->buildQuery( [ 'userExpLevel' => 'unregistered;experienced' ] ); $this->assertRegExp( - '/\(rc_user = 0\) OR \(\(user_editcount >= 500\) AND \(user_registration <= \'[^\']+\'\)\)/', + '/\(COALESCE\( actor_rc_user.actor_user, rc_user \) = 0\) OR ' + . '\(\(user_editcount >= 500\) AND \(user_registration <= \'[^\']+\'\)\)/', reset( $conds ), "rc conditions: userExpLevel=unregistered;experienced" ); @@ -595,8 +623,10 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase ] ); + // @todo: This is not at all safe or sane. It just blindly assumes + // nothing in $conds depends on any other tables. $result = wfGetDB( DB_MASTER )->select( - $tables, + 'user', 'user_name', array_filter( $conds ) + [ 'user_email' => 'ut' ] ); diff --git a/tests/phpunit/includes/specials/SpecialUploadTest.php b/tests/phpunit/includes/specials/SpecialUploadTest.php new file mode 100644 index 0000000000..95026c18d0 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialUploadTest.php @@ -0,0 +1,29 @@ +assertEquals( $expected, $result ); + } + + public function provideGetInitialPageText() { + return [ + [ + 'expect' => "== Summary ==\nthis is a test\n", + 'params' => [ + 'this is a test' + ], + ], + [ + 'expect' => "== Summary ==\nthis is a test\n", + 'params' => [ + "== Summary ==\nthis is a test", + ], + ], + ]; + } +} diff --git a/tests/phpunit/includes/user/UserGroupMembershipTest.php b/tests/phpunit/includes/user/UserGroupMembershipTest.php index f95e38715a..4862747b4f 100644 --- a/tests/phpunit/includes/user/UserGroupMembershipTest.php +++ b/tests/phpunit/includes/user/UserGroupMembershipTest.php @@ -4,6 +4,9 @@ * @group Database */ class UserGroupMembershipTest extends MediaWikiTestCase { + + protected $tablesUsed = [ 'user', 'user_groups' ]; + /** * @var User Belongs to no groups */ diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 4c1a5fd103..c225ba5b8c 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -21,7 +21,9 @@ class UserTest extends MediaWikiTestCase { $this->setMwGlobals( [ 'wgGroupPermissions' => [], 'wgRevokePermissions' => [], + 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH, ] ); + $this->overrideMwServices(); $this->setUpPermissionGlobals(); @@ -617,6 +619,7 @@ class UserTest extends MediaWikiTestCase { 'enableAutoblock' => true, 'expiry' => wfTimestamp( TS_MW, $expiryFiveHours ), ] ); + $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1tmp ); $block->setBlocker( $userBlocker ); $res = $block->insert(); @@ -694,6 +697,7 @@ class UserTest extends MediaWikiTestCase { $request1 = new FauxRequest(); $request1->getSession()->setUser( $testUser ); $block = new Block( [ 'enableAutoblock' => true ] ); + $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $testUser ); $block->setBlocker( $userBlocker ); $res = $block->insert(); @@ -739,6 +743,7 @@ class UserTest extends MediaWikiTestCase { $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1Tmp ); $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] ); + $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1Tmp ); $block->setBlocker( $userBlocker ); $res = $block->insert(); @@ -834,6 +839,7 @@ class UserTest extends MediaWikiTestCase { $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1tmp ); $block = new Block( [ 'enableAutoblock' => true ] ); + $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1tmp ); $block->setBlocker( $userBlocker ); $res = $block->insert(); @@ -879,6 +885,7 @@ class UserTest extends MediaWikiTestCase { $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1tmp ); $block = new Block( [ 'enableAutoblock' => true ] ); + $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1tmp ); $block->setBlocker( $userBlocker ); $res = $block->insert(); @@ -956,20 +963,18 @@ class UserTest extends MediaWikiTestCase { ] ); $db = wfGetDB( DB_MASTER ); - - $data = new stdClass(); - $data->user_id = 1; - $data->user_name = 'name'; - $data->user_real_name = 'Real Name'; - $data->user_touched = 1; - $data->user_token = 'token'; - $data->user_email = 'a@a.a'; - $data->user_email_authenticated = null; - $data->user_email_token = 'token'; - $data->user_email_token_expires = null; - $data->user_editcount = $editCount; - $data->user_registration = $db->timestamp( time() - $memberSince * 86400 ); - $user = User::newFromRow( $data ); + $userQuery = User::getQueryInfo(); + $row = $db->selectRow( + $userQuery['tables'], + $userQuery['fields'], + [ 'user_id' => $this->getTestUser()->getUser()->getId() ], + __METHOD__, + [], + $userQuery['joins'] + ); + $row->user_editcount = $editCount; + $row->user_registration = $db->timestamp( time() - $memberSince * 86400 ); + $user = User::newFromRow( $row ); $this->assertEquals( $expLevel, $user->getExperienceLevel() ); } @@ -1028,4 +1033,113 @@ class UserTest extends MediaWikiTestCase { ); $this->assertTrue( User::isLocallyBlockedProxy( $ip ) ); } + + public function testActorId() { + $this->hideDeprecated( 'User::selectFields' ); + + // Newly-created user has an actor ID + $user = User::createNew( 'UserTestActorId1' ); + $id = $user->getId(); + $this->assertTrue( $user->getActorId() > 0, 'User::createNew sets an actor ID' ); + + $user = User::newFromName( 'UserTestActorId2' ); + $user->addToDatabase(); + $this->assertTrue( $user->getActorId() > 0, 'User::addToDatabase sets an actor ID' ); + + $user = User::newFromName( 'UserTestActorId1' ); + $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be retrieved for user loaded by name' ); + + $user = User::newFromId( $id ); + $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be retrieved for user loaded by ID' ); + + $user2 = User::newFromActorId( $user->getActorId() ); + $this->assertEquals( $user->getId(), $user2->getId(), + 'User::newFromActorId works for an existing user' ); + + $row = $this->db->selectRow( 'user', User::selectFields(), [ 'user_id' => $id ], __METHOD__ ); + $user = User::newFromRow( $row ); + $this->assertTrue( $user->getActorId() > 0, + 'Actor ID can be retrieved for user loaded with User::selectFields()' ); + + $this->db->delete( 'actor', [ 'actor_user' => $id ], __METHOD__ ); + User::purge( wfWikiId(), $id ); + // Because WANObjectCache->delete() stupidly doesn't delete from the process cache. + ObjectCache::getMainWANInstance()->clearProcessCache(); + + $user = User::newFromId( $id ); + $this->assertFalse( $user->getActorId() > 0, 'No Actor ID by default if none in database' ); + $this->assertTrue( $user->getActorId( $this->db ) > 0, 'Actor ID can be created if none in db' ); + + $user->setName( 'UserTestActorId4-renamed' ); + $user->saveSettings(); + $this->assertEquals( + $user->getName(), + $this->db->selectField( + 'actor', 'actor_name', [ 'actor_id' => $user->getActorId() ], __METHOD__ + ), + 'User::saveSettings updates actor table for name change' + ); + + // For sanity + $ip = '192.168.12.34'; + $this->db->delete( 'actor', [ 'actor_name' => $ip ], __METHOD__ ); + + $user = User::newFromName( $ip, false ); + $this->assertFalse( $user->getActorId() > 0, 'Anonymous user has no actor ID by default' ); + $this->assertTrue( $user->getActorId( $this->db ) > 0, + 'Actor ID can be created for an anonymous user' ); + + $user = User::newFromName( $ip, false ); + $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be loaded for an anonymous user' ); + $user2 = User::newFromActorId( $user->getActorId() ); + $this->assertEquals( $user->getName(), $user2->getName(), + 'User::newFromActorId works for an anonymous user' ); + } + + public function testNewFromAnyId() { + // Registered user + $user = $this->getTestUser()->getUser(); + for ( $i = 1; $i <= 7; $i++ ) { + $test = User::newFromAnyId( + ( $i & 1 ) ? $user->getId() : null, + ( $i & 2 ) ? $user->getName() : null, + ( $i & 4 ) ? $user->getActorId() : null + ); + $this->assertSame( $user->getId(), $test->getId() ); + $this->assertSame( $user->getName(), $test->getName() ); + $this->assertSame( $user->getActorId(), $test->getActorId() ); + } + + // Anon user. Can't load by only user ID when that's 0. + $user = User::newFromName( '192.168.12.34', false ); + $user->getActorId( $this->db ); // Make sure an actor ID exists + + $test = User::newFromAnyId( null, '192.168.12.34', null ); + $this->assertSame( $user->getId(), $test->getId() ); + $this->assertSame( $user->getName(), $test->getName() ); + $this->assertSame( $user->getActorId(), $test->getActorId() ); + $test = User::newFromAnyId( null, null, $user->getActorId() ); + $this->assertSame( $user->getId(), $test->getId() ); + $this->assertSame( $user->getName(), $test->getName() ); + $this->assertSame( $user->getActorId(), $test->getActorId() ); + + // Bogus data should still "work" as long as nothing triggers a ->load(), + // and accessing the specified data shouldn't do that. + $test = User::newFromAnyId( 123456, 'Bogus', 654321 ); + $this->assertSame( 123456, $test->getId() ); + $this->assertSame( 'Bogus', $test->getName() ); + $this->assertSame( 654321, $test->getActorId() ); + + // Exceptional cases + try { + User::newFromAnyId( null, null, null ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + } + try { + User::newFromAnyId( 0, null, 0 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + } + } } diff --git a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php index b6cf2396e1..be51626a6f 100644 --- a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php +++ b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php @@ -30,6 +30,40 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { return $mockStore; } + /** + * @return PHPUnit_Framework_MockObject_MockObject|ActorMigration + */ + private function getMockActorMigration() { + $mockStore = $this->getMockBuilder( ActorMigration::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockStore->expects( $this->any() ) + ->method( 'getJoin' ) + ->willReturn( [ + 'tables' => [ 'actormigration' => 'table' ], + 'fields' => [ + 'rc_user' => 'actormigration_user', + 'rc_user_text' => 'actormigration_user_text', + 'rc_actor' => 'actormigration_actor', + ], + 'joins' => [ 'actormigration' => 'join' ], + ] ); + $mockStore->expects( $this->any() ) + ->method( 'getWhere' ) + ->willReturn( [ + 'tables' => [ 'actormigration' => 'table' ], + 'conds' => 'actormigration_conds', + 'joins' => [ 'actormigration' => 'join' ], + ] ); + $mockStore->expects( $this->any() ) + ->method( 'isAnon' ) + ->willReturn( 'actormigration is anon' ); + $mockStore->expects( $this->any() ) + ->method( 'isNotAnon' ) + ->willReturn( 'actormigration is not anon' ); + return $mockStore; + } + /** * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb * @return WatchedItemQueryService @@ -37,7 +71,8 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { private function newService( $mockDb ) { return new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ), - $this->getMockCommentStore() + $this->getMockCommentStore(), + $this->getMockActorMigration() ); } @@ -57,10 +92,17 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { ) ->will( $this->returnCallback( function ( $a, $conj ) { $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; - return implode( $sqlConj, array_map( function ( $s ) { - return '(' . $s . ')'; - }, $a - ) ); + $conds = []; + foreach ( $a as $k => $v ) { + if ( is_int( $k ) ) { + $conds[] = "($v)"; + } elseif ( is_array( $v ) ) { + $conds[] = "($k IN ('" . implode( "','", $v ) . "'))"; + } else { + $conds[] = "($k = '$v')"; + } + } + return implode( $sqlConj, $conds ); } ) ); $mock->expects( $this->any() ) @@ -490,20 +532,20 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { [ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], null, - [], - [ 'rc_user_text' ], - [], + [ 'actormigration' => 'table' ], + [ 'rc_user_text' => 'actormigration_user_text' ], [], [], + [ 'actormigration' => 'join' ], ], [ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], null, - [], - [ 'rc_user' ], - [], + [ 'actormigration' => 'table' ], + [ 'rc_user' => 'actormigration_user' ], [], [], + [ 'actormigration' => 'join' ], ], [ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], @@ -705,20 +747,20 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { [ [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], null, + [ 'actormigration' => 'table' ], [], + [ 'actormigration is anon' ], [], - [ 'rc_user = 0' ], - [], - [], + [ 'actormigration' => 'join' ], ], [ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], null, + [ 'actormigration' => 'table' ], [], + [ 'actormigration is not anon' ], [], - [ 'rc_user != 0' ], - [], - [], + [ 'actormigration' => 'join' ], ], [ [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], @@ -759,20 +801,20 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { [ [ 'onlyByUser' => 'SomeOtherUser' ], null, + [ 'actormigration' => 'table' ], [], + [ 'actormigration_conds' ], [], - [ 'rc_user_text' => 'SomeOtherUser' ], - [], - [], + [ 'actormigration' => 'join' ], ], [ [ 'notByUser' => 'SomeOtherUser' ], null, + [ 'actormigration' => 'table' ], [], + [ 'NOT(actormigration_conds)' ], [], - [ "rc_user_text != 'SomeOtherUser'" ], - [], - [], + [ 'actormigration' => 'join' ], ], [ [ 'dir' => WatchedItemQueryService::DIR_OLDER ], @@ -984,62 +1026,74 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { [ [], 'deletedhistory', + [], [ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . LogPage::DELETED_ACTION . ')' ], + [], ], [ [], 'suppressrevision', + [], [ '(rc_type != ' . RC_LOG . ') OR (' . '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' ], + [], ], [ [], 'viewsuppressed', + [], [ '(rc_type != ' . RC_LOG . ') OR (' . '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' ], + [], ], [ [ 'onlyByUser' => 'SomeOtherUser' ], 'deletedhistory', + [ 'actormigration' => 'table' ], [ - 'rc_user_text' => 'SomeOtherUser', + 'actormigration_conds', '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER, '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . LogPage::DELETED_ACTION . ')' ], + [ 'actormigration' => 'join' ], ], [ [ 'onlyByUser' => 'SomeOtherUser' ], 'suppressrevision', + [ 'actormigration' => 'table' ], [ - 'rc_user_text' => 'SomeOtherUser', + 'actormigration_conds', '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), '(rc_type != ' . RC_LOG . ') OR (' . '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' ], + [ 'actormigration' => 'join' ], ], [ [ 'onlyByUser' => 'SomeOtherUser' ], 'viewsuppressed', + [ 'actormigration' => 'table' ], [ - 'rc_user_text' => 'SomeOtherUser', + 'actormigration_conds', '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), '(rc_type != ' . RC_LOG . ') OR (' . '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' ], + [ 'actormigration' => 'join' ], ], ]; } @@ -1050,7 +1104,9 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks( array $options, $notAllowedAction, - array $expectedExtraConds + array $expectedExtraTables, + array $expectedExtraConds, + array $expectedExtraJoins ) { $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; $conds = array_merge( $commonConds, $expectedExtraConds ); @@ -1059,12 +1115,15 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { $mockDb->expects( $this->once() ) ->method( 'select' ) ->with( - [ 'recentchanges', 'watchlist', 'page' ], + array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ), $this->isType( 'array' ), $conds, $this->isType( 'string' ), $this->isType( 'array' ), - $this->isType( 'array' ) + array_merge( [ + 'watchlist' => [ 'INNER JOIN', [ 'wl_namespace=rc_namespace', 'wl_title=rc_title' ] ], + 'page' => [ 'LEFT JOIN', 'rc_cur_id=page_id' ], + ], $expectedExtraJoins ) ) ->will( $this->returnValue( [] ) ); diff --git a/tests/phpunit/languages/LanguageTest.php b/tests/phpunit/languages/LanguageTest.php index 5cb560299f..050ed83bed 100644 --- a/tests/phpunit/languages/LanguageTest.php +++ b/tests/phpunit/languages/LanguageTest.php @@ -209,70 +209,104 @@ class LanguageTest extends LanguageClassesTestCase { } /** - * @covers Language::truncate + * @covers Language::truncateForDatabase + * @covers Language::truncateInternal */ - public function testTruncate() { + public function testTruncateForDatabase() { $this->assertEquals( "XXX", - $this->getLang()->truncate( "1234567890", 0, 'XXX' ), + $this->getLang()->truncateForDatabase( "1234567890", 0, 'XXX' ), 'truncate prefix, len 0, small ellipsis' ); $this->assertEquals( "12345XXX", - $this->getLang()->truncate( "1234567890", 8, 'XXX' ), + $this->getLang()->truncateForDatabase( "1234567890", 8, 'XXX' ), 'truncate prefix, small ellipsis' ); $this->assertEquals( "123456789", - $this->getLang()->truncate( "123456789", 5, 'XXXXXXXXXXXXXXX' ), + $this->getLang()->truncateForDatabase( "123456789", 5, 'XXXXXXXXXXXXXXX' ), 'truncate prefix, large ellipsis' ); $this->assertEquals( "XXX67890", - $this->getLang()->truncate( "1234567890", -8, 'XXX' ), + $this->getLang()->truncateForDatabase( "1234567890", -8, 'XXX' ), 'truncate suffix, small ellipsis' ); $this->assertEquals( "123456789", - $this->getLang()->truncate( "123456789", -5, 'XXXXXXXXXXXXXXX' ), + $this->getLang()->truncateForDatabase( "123456789", -5, 'XXXXXXXXXXXXXXX' ), 'truncate suffix, large ellipsis' ); $this->assertEquals( "123XXX", - $this->getLang()->truncate( "123 ", 9, 'XXX' ), + $this->getLang()->truncateForDatabase( "123 ", 9, 'XXX' ), 'truncate prefix, with spaces' ); $this->assertEquals( "12345XXX", - $this->getLang()->truncate( "12345 8", 11, 'XXX' ), + $this->getLang()->truncateForDatabase( "12345 8", 11, 'XXX' ), 'truncate prefix, with spaces and non-space ending' ); $this->assertEquals( "XXX234", - $this->getLang()->truncate( "1 234", -8, 'XXX' ), + $this->getLang()->truncateForDatabase( "1 234", -8, 'XXX' ), 'truncate suffix, with spaces' ); $this->assertEquals( "12345XXX", - $this->getLang()->truncate( "1234567890", 5, 'XXX', false ), + $this->getLang()->truncateForDatabase( "1234567890", 5, 'XXX', false ), 'truncate without adjustment' ); $this->assertEquals( "泰乐菌...", - $this->getLang()->truncate( "泰乐菌素123456789", 11, '...', false ), + $this->getLang()->truncateForDatabase( "泰乐菌素123456789", 11, '...', false ), 'truncate does not chop Unicode characters in half' ); $this->assertEquals( "\n泰乐菌...", - $this->getLang()->truncate( "\n泰乐菌素123456789", 12, '...', false ), + $this->getLang()->truncateForDatabase( "\n泰乐菌素123456789", 12, '...', false ), 'truncate does not chop Unicode characters in half if there is a preceding newline' ); } + /** + * @dataProvider provideTruncateData + * @covers Language::truncateForVisual + * @covers Language::truncateInternal + */ + public function testTruncateForVisual( + $expected, $string, $length, $ellipsis = '...', $adjustLength = true + ) { + $this->assertEquals( + $expected, + $this->getLang()->truncateForVisual( $string, $length, $ellipsis, $adjustLength ) + ); + } + + /** + * @return array Format is ($expected, $string, $length, $ellipsis, $adjustLength) + */ + public static function provideTruncateData() { + return [ + [ "XXX", "тестирам да ли ради", 0, "XXX" ], + [ "testnXXX", "testni scenarij", 8, "XXX" ], + [ "حالة اختبار", "حالة اختبار", 5, "XXXXXXXXXXXXXXX" ], + [ "XXXедент", "прецедент", -8, "XXX" ], + [ "XXപിൾ", "ആപ്പിൾ", -5, "XX" ], + [ "神秘XXX", "神秘 ", 9, "XXX" ], + [ "ΔημιουργXXX", "Δημιουργία Σύμπαντος", 11, "XXX" ], + [ "XXXの家です", "地球は私たちの唯 の家です", -8, "XXX" ], + [ "زندگیXXX", "زندگی زیباست", 6, "XXX", false ], + [ "ცხოვრება...", "ცხოვრება არის საოცარი", 8, "...", false ], + [ "\nທ່ານ...", "\nທ່ານບໍ່ຮູ້ຫນັງສື", 5, "...", false ], + ]; + } + /** * @dataProvider provideHTMLTruncateData * @covers Language::truncateHTML diff --git a/tests/phpunit/languages/SpecialPageAliasTest.php b/tests/phpunit/languages/SpecialPageAliasTest.php index 4a7fed2a06..0bb6a4d293 100644 --- a/tests/phpunit/languages/SpecialPageAliasTest.php +++ b/tests/phpunit/languages/SpecialPageAliasTest.php @@ -25,7 +25,7 @@ class SpecialPageAliasTest extends MediaWikiTestCase { } public function validSpecialPageAliasesProvider() { - $codes = array_keys( Language::fetchLanguageNames( 'mwfile' ) ); + $codes = array_keys( Language::fetchLanguageNames( null, 'mwfile' ) ); $data = []; diff --git a/tests/phpunit/languages/classes/LanguageCrhTest.php b/tests/phpunit/languages/classes/LanguageCrhTest.php index d99fc26729..7c99614e61 100644 --- a/tests/phpunit/languages/classes/LanguageCrhTest.php +++ b/tests/phpunit/languages/classes/LanguageCrhTest.php @@ -55,6 +55,22 @@ class LanguageCrhTest extends LanguageClassesTestCase { ], 'инструменталь instrumental гургуль gürgül тюшюнмемек tüşünmemek' ], + [ // recent problem words, part 1 + [ + 'crh' => 'künü куню sürgünligi сюргюнлиги özü озю etti этти', + 'crh-cyrl' => 'куню куню сюргюнлиги сюргюнлиги озю озю этти этти', + 'crh-latn' => 'künü künü sürgünligi sürgünligi özü özü etti etti', + ], + 'künü куню sürgünligi сюргюнлиги özü озю etti этти' + ], + [ // recent problem words, part 2 + [ + 'crh' => 'esas эсас dört дёрт keldi кельди', + 'crh-cyrl' => 'эсас эсас дёрт дёрт кельди кельди', + 'crh-latn' => 'esas esas dört dört keldi keldi', + ], + 'esas эсас dört дёрт keldi кельди' + ], [ // multi part words [ 'crh' => 'эки юз eki yüz', @@ -63,7 +79,7 @@ class LanguageCrhTest extends LanguageClassesTestCase { ], 'эки юз eki yüz' ], - [ // ALL CAPS, made up acronyms + [ // ALL CAPS, made up acronyms (not 100% sure these are correct) [ 'crh' => 'ÑAB QIC ĞUK COT НЪАБ КЪЫДж ГЪУК ДЖОТ CA ДЖА', 'crh-cyrl' => 'НЪАБ КЪЫДж ГЪУК ДЖОТ НЪАБ КЪЫДж ГЪУК ДЖОТ ДЖА ДЖА', diff --git a/tests/phpunit/structure/StructureTest.php b/tests/phpunit/structure/StructureTest.php index 35434634cf..4df791ecd8 100644 --- a/tests/phpunit/structure/StructureTest.php +++ b/tests/phpunit/structure/StructureTest.php @@ -26,6 +26,7 @@ class StructureTest extends MediaWikiTestCase { 'ResourceLoaderTestCase', 'PHPUnit_Framework_TestCase', '\\?PHPUnit\\Framework\\TestCase', + 'TestCase', // \PHPUnit\Framework\TestCase with appropriate use statement 'DumpTestCase', ] ); $testClassRegex = "^class .* extends ($testClassRegex)"; diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 8390ab3c58..785e11462d 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -45,13 +45,12 @@ return [ 'scripts' => [ 'tests/qunit/suites/resources/startup.test.js', 'tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js', - 'tests/qunit/suites/resources/jquery/jquery.byteLength.test.js', - 'tests/qunit/suites/resources/jquery/jquery.byteLimit.test.js', 'tests/qunit/suites/resources/jquery/jquery.color.test.js', 'tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js', 'tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js', 'tests/qunit/suites/resources/jquery/jquery.hidpi.test.js', 'tests/qunit/suites/resources/jquery/jquery.highlightText.test.js', + 'tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js', 'tests/qunit/suites/resources/jquery/jquery.localize.test.js', 'tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js', 'tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js', @@ -65,6 +64,8 @@ return [ 'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js', @@ -102,13 +103,12 @@ return [ ], 'dependencies' => [ 'jquery.accessKeyLabel', - 'jquery.byteLength', - 'jquery.byteLimit', 'jquery.color', 'jquery.colorUtil', 'jquery.getAttrs', 'jquery.hidpi', 'jquery.highlightText', + 'jquery.lengthLimit', 'jquery.localize', 'jquery.makeCollapsible', 'jquery.tabIndex', @@ -125,6 +125,7 @@ return [ 'mediawiki.jqueryMsg', 'mediawiki.messagePoster', 'mediawiki.RegExp', + 'mediawiki.String', 'mediawiki.storage', 'mediawiki.Title', 'mediawiki.toc', diff --git a/tests/qunit/suites/resources/jquery/jquery.byteLength.test.js b/tests/qunit/suites/resources/jquery/jquery.byteLength.test.js deleted file mode 100644 index 558e64161d..0000000000 --- a/tests/qunit/suites/resources/jquery/jquery.byteLength.test.js +++ /dev/null @@ -1,37 +0,0 @@ -( function ( $ ) { - QUnit.module( 'jquery.byteLength', QUnit.newMwEnvironment() ); - - QUnit.test( 'Simple text', function ( assert ) { - var azLc = 'abcdefghijklmnopqrstuvwxyz', - azUc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', - num = '0123456789', - x = '*', - space = ' '; - - assert.equal( $.byteLength( azLc ), 26, 'Lowercase a-z' ); - assert.equal( $.byteLength( azUc ), 26, 'Uppercase A-Z' ); - assert.equal( $.byteLength( num ), 10, 'Numbers 0-9' ); - assert.equal( $.byteLength( x ), 1, 'An asterisk' ); - assert.equal( $.byteLength( space ), 3, '3 spaces' ); - - } ); - - QUnit.test( 'Special text', function ( assert ) { - // https://en.wikipedia.org/wiki/UTF-8 - var u0024 = '$', - // Cent symbol - u00A2 = '\u00A2', - // Euro symbol - u20AC = '\u20AC', - // Character \U00024B62 (Han script) can't be represented in javascript as a single - // code point, instead it is composed as a surrogate pair of two separate code units. - // http://codepoints.net/U+24B62 - // http://www.fileformat.info/info/unicode/char/24B62/index.htm - u024B62 = '\uD852\uDF62'; - - assert.strictEqual( $.byteLength( u0024 ), 1, 'U+0024' ); - assert.strictEqual( $.byteLength( u00A2 ), 2, 'U+00A2' ); - assert.strictEqual( $.byteLength( u20AC ), 3, 'U+20AC' ); - assert.strictEqual( $.byteLength( u024B62 ), 4, 'U+024B62 (surrogate pair: \\uD852\\uDF62)' ); - } ); -}( jQuery ) ); diff --git a/tests/qunit/suites/resources/jquery/jquery.byteLimit.test.js b/tests/qunit/suites/resources/jquery/jquery.byteLimit.test.js deleted file mode 100644 index 8555a7e4d8..0000000000 --- a/tests/qunit/suites/resources/jquery/jquery.byteLimit.test.js +++ /dev/null @@ -1,248 +0,0 @@ -( function ( $, mw ) { - var simpleSample, U_20AC, mbSample; - - QUnit.module( 'jquery.byteLimit', QUnit.newMwEnvironment() ); - - // Simple sample (20 chars, 20 bytes) - simpleSample = '12345678901234567890'; - - // 3 bytes (euro-symbol) - U_20AC = '\u20AC'; - - // Multi-byte sample (22 chars, 26 bytes) - mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC; - - // Basic sendkey-implementation - function addChars( $input, charstr ) { - var c, len; - - function x( $input, i ) { - // Add character to the value - return $input.val() + charstr.charAt( i ); - } - - for ( c = 0, len = charstr.length; c < len; c += 1 ) { - $input - .val( x( $input, c ) ) - .trigger( 'change' ); - } - } - - /** - * Test factory for $.fn.byteLimit - * - * @param {Object} options - * @param {string} options.description Test name - * @param {jQuery} options.$input jQuery object in an input element - * @param {string} options.sample Sequence of characters to simulate being - * added one by one - * @param {string} options.expected Expected final value of `$input` - */ - function byteLimitTest( options ) { - var opt = $.extend( { - description: '', - $input: null, - sample: '', - expected: '' - }, options ); - - QUnit.test( opt.description, function ( assert ) { - opt.$input.appendTo( '#qunit-fixture' ); - - // Simulate pressing keys for each of the sample characters - addChars( opt.$input, opt.sample ); - - assert.equal( - opt.$input.val(), - opt.expected, - 'New value matches the expected string' - ); - } ); - } - - byteLimitTest( { - description: 'Plain text input', - $input: $( '' ).attr( 'type', 'text' ), - sample: simpleSample, - expected: simpleSample - } ); - - byteLimitTest( { - description: 'Plain text input. Calling byteLimit with no parameters and no maxlength attribute (T38310)', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit(), - sample: simpleSample, - expected: simpleSample - } ); - - byteLimitTest( { - description: 'Limit using the maxlength attribute', - $input: $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '10' ) - .byteLimit(), - sample: simpleSample, - expected: '1234567890' - } ); - - byteLimitTest( { - description: 'Limit using a custom value', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 10 ), - sample: simpleSample, - expected: '1234567890' - } ); - - byteLimitTest( { - description: 'Limit using a custom value, overriding maxlength attribute', - $input: $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '10' ) - .byteLimit( 15 ), - sample: simpleSample, - expected: '123456789012345' - } ); - - byteLimitTest( { - description: 'Limit using a custom value (multibyte)', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 14 ), - sample: mbSample, - expected: '1234567890' + U_20AC + '1' - } ); - - byteLimitTest( { - description: 'Limit using a custom value (multibyte) overlapping a byte', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 12 ), - sample: mbSample, - expected: '123456789012' - } ); - - byteLimitTest( { - description: 'Pass the limit and a callback as input filter', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 6, function ( val ) { - var title = mw.Title.newFromText( String( val ) ); - // Return without namespace prefix - return title ? title.getMain() : ''; - } ), - sample: 'User:Sample', - expected: 'User:Sample' - } ); - - byteLimitTest( { - description: 'Limit using the maxlength attribute and pass a callback as input filter', - $input: $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '6' ) - .byteLimit( function ( val ) { - var title = mw.Title.newFromText( String( val ) ); - // Return without namespace prefix - return title ? title.getMain() : ''; - } ), - sample: 'User:Sample', - expected: 'User:Sample' - } ); - - byteLimitTest( { - description: 'Pass the limit and a callback as input filter', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 6, function ( val ) { - var title = mw.Title.newFromText( String( val ) ); - // Return without namespace prefix - return title ? title.getMain() : ''; - } ), - sample: 'User:Example', - // The callback alters the value to be used to calculeate - // the length. The altered value is "Exampl" which has - // a length of 6, the "e" would exceed the limit. - expected: 'User:Exampl' - } ); - - byteLimitTest( { - description: 'Input filter that increases the length', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 10, function ( text ) { - return 'prefix' + text; - } ), - sample: simpleSample, - // Prefix adds 6 characters, limit is reached after 4 - expected: '1234' - } ); - - // Regression tests for T43450 - byteLimitTest( { - description: 'Input filter of which the base exceeds the limit', - $input: $( '' ).attr( 'type', 'text' ) - .byteLimit( 3, function ( text ) { - return 'prefix' + text; - } ), - sample: simpleSample, - hasLimit: true, - limit: 6, // 'prefix' length - expected: '' - } ); - - QUnit.test( 'Confirm properties and attributes set', function ( assert ) { - var $el; - - $el = $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '7' ) - .appendTo( '#qunit-fixture' ) - .byteLimit(); - - assert.strictEqual( $el.attr( 'maxlength' ), '7', 'maxlength attribute unchanged for simple limit' ); - - $el = $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '7' ) - .appendTo( '#qunit-fixture' ) - .byteLimit( 12 ); - - assert.strictEqual( $el.attr( 'maxlength' ), '12', 'maxlength attribute updated for custom limit' ); - - $el = $( '' ).attr( 'type', 'text' ) - .attr( 'maxlength', '7' ) - .appendTo( '#qunit-fixture' ) - .byteLimit( 12, function ( val ) { - return val; - } ); - - assert.strictEqual( $el.attr( 'maxlength' ), undefined, 'maxlength attribute removed for limit with callback' ); - - $( '' ).attr( 'type', 'text' ) - .addClass( 'mw-test-byteLimit-foo' ) - .attr( 'maxlength', '7' ) - .appendTo( '#qunit-fixture' ); - - $( '' ).attr( 'type', 'text' ) - .addClass( 'mw-test-byteLimit-foo' ) - .attr( 'maxlength', '12' ) - .appendTo( '#qunit-fixture' ); - - $el = $( '.mw-test-byteLimit-foo' ); - - assert.strictEqual( $el.length, 2, 'Verify that there are no other elements clashing with this test suite' ); - - $el.byteLimit(); - } ); - - QUnit.test( 'Trim from insertion when limit exceeded', function ( assert ) { - var $el; - - // Use a new because the bug only occurs on the first time - // the limit it reached (T42850) - $el = $( '' ).attr( 'type', 'text' ) - .appendTo( '#qunit-fixture' ) - .byteLimit( 3 ) - .val( 'abc' ).trigger( 'change' ) - .val( 'zabc' ).trigger( 'change' ); - - assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 0), not the end' ); - - $el = $( '' ).attr( 'type', 'text' ) - .appendTo( '#qunit-fixture' ) - .byteLimit( 3 ) - .val( 'abc' ).trigger( 'change' ) - .val( 'azbc' ).trigger( 'change' ); - - assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 1), not the end' ); - } ); -}( jQuery, mediaWiki ) ); diff --git a/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js b/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js new file mode 100644 index 0000000000..7117d1f420 --- /dev/null +++ b/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js @@ -0,0 +1,286 @@ +( function ( $, mw ) { + var simpleSample, U_20AC, poop, mbSample; + + QUnit.module( 'jquery.lengthLimit', QUnit.newMwEnvironment() ); + + // Simple sample (20 chars, 20 bytes) + simpleSample = '12345678901234567890'; + + // 3 bytes (euro-symbol) + U_20AC = '\u20AC'; + + // Outside of the BMP (pile of poo emoji) + poop = '\uD83D\uDCA9'; // "💩" + + // Multi-byte sample (22 chars, 26 bytes) + mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC; + + // Basic sendkey-implementation + function addChars( $input, charstr ) { + var c, len; + + function x( $input, i ) { + // Add character to the value + return $input.val() + charstr.charAt( i ); + } + + for ( c = 0, len = charstr.length; c < len; c += 1 ) { + $input + .val( x( $input, c ) ) + .trigger( 'change' ); + } + } + + /** + * Test factory for $.fn.byteLimit + * + * @param {Object} options + * @param {string} options.description Test name + * @param {jQuery} options.$input jQuery object in an input element + * @param {string} options.sample Sequence of characters to simulate being + * added one by one + * @param {string} options.expected Expected final value of `$input` + */ + function byteLimitTest( options ) { + var opt = $.extend( { + description: '', + $input: null, + sample: '', + expected: '' + }, options ); + + QUnit.test( opt.description, function ( assert ) { + opt.$input.appendTo( '#qunit-fixture' ); + + // Simulate pressing keys for each of the sample characters + addChars( opt.$input, opt.sample ); + + assert.equal( + opt.$input.val(), + opt.expected, + 'New value matches the expected string' + ); + } ); + } + + byteLimitTest( { + description: 'Plain text input', + $input: $( '' ).attr( 'type', 'text' ), + sample: simpleSample, + expected: simpleSample + } ); + + byteLimitTest( { + description: 'Plain text input. Calling byteLimit with no parameters and no maxlength attribute (T38310)', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit(), + sample: simpleSample, + expected: simpleSample + } ); + + byteLimitTest( { + description: 'Limit using the maxlength attribute', + $input: $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '10' ) + .byteLimit(), + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 10 ), + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value, overriding maxlength attribute', + $input: $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '10' ) + .byteLimit( 15 ), + sample: simpleSample, + expected: '123456789012345' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte)', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 14 ), + sample: mbSample, + expected: '1234567890' + U_20AC + '1' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte, outside BMP)', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 3 ), + sample: poop, + expected: '' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte) overlapping a byte', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 12 ), + sample: mbSample, + expected: '123456789012' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 6, function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Limit using the maxlength attribute and pass a callback as input filter', + $input: $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '6' ) + .byteLimit( function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 6, function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Example', + // The callback alters the value to be used to calculeate + // the length. The altered value is "Exampl" which has + // a length of 6, the "e" would exceed the limit. + expected: 'User:Exampl' + } ); + + byteLimitTest( { + description: 'Input filter that increases the length', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 10, function ( text ) { + return 'prefix' + text; + } ), + sample: simpleSample, + // Prefix adds 6 characters, limit is reached after 4 + expected: '1234' + } ); + + // Regression tests for T43450 + byteLimitTest( { + description: 'Input filter of which the base exceeds the limit', + $input: $( '' ).attr( 'type', 'text' ) + .byteLimit( 3, function ( text ) { + return 'prefix' + text; + } ), + sample: simpleSample, + expected: '' + } ); + + QUnit.test( 'Confirm properties and attributes set', function ( assert ) { + var $el; + + $el = $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit(); + + assert.strictEqual( $el.attr( 'maxlength' ), '7', 'maxlength attribute unchanged for simple limit' ); + + $el = $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12 ); + + assert.strictEqual( $el.attr( 'maxlength' ), '12', 'maxlength attribute updated for custom limit' ); + + $el = $( '' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12, function ( val ) { + return val; + } ); + + assert.strictEqual( $el.attr( 'maxlength' ), undefined, 'maxlength attribute removed for limit with callback' ); + + $( '' ).attr( 'type', 'text' ) + .addClass( 'mw-test-byteLimit-foo' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ); + + $( '' ).attr( 'type', 'text' ) + .addClass( 'mw-test-byteLimit-foo' ) + .attr( 'maxlength', '12' ) + .appendTo( '#qunit-fixture' ); + + $el = $( '.mw-test-byteLimit-foo' ); + + assert.strictEqual( $el.length, 2, 'Verify that there are no other elements clashing with this test suite' ); + + $el.byteLimit(); + } ); + + QUnit.test( 'Trim from insertion when limit exceeded', function ( assert ) { + var $el; + + // Use a new because the bug only occurs on the first time + // the limit it reached (T42850) + $el = $( '' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 3 ) + .val( 'abc' ).trigger( 'change' ) + .val( 'zabc' ).trigger( 'change' ); + + assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 0), not the end' ); + + $el = $( '' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 3 ) + .val( 'abc' ).trigger( 'change' ) + .val( 'azbc' ).trigger( 'change' ); + + assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 1), not the end' ); + } ); + + QUnit.test( 'Do not cut up false matching substrings in emoji insertions', function ( assert ) { + var $el, + oldVal = '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩" + newVal = '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩" + expected = '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9'; // "💩💹💩" + + // Possible bad results: + // * With no surrogate support: + // '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9' "💩💹🢩" + // * With correct trimming but bad detection of inserted text: + // '\uD83D\uDCA9\uD83D\uDCB9\uDCA9' "💩💹�" + + $el = $( '' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12 ) + .val( oldVal ).trigger( 'change' ) + .val( newVal ).trigger( 'change' ); + + assert.strictEqual( $el.val(), expected, 'Pasted emoji correctly trimmed at the end' ); + } ); + + byteLimitTest( { + description: 'Unpaired surrogates do not crash', + $input: $( '' ).attr( 'type', 'text' ).byteLimit( 4 ), + sample: '\uD800\uD800\uDFFF', + expected: '\uD800' + } ); + +}( jQuery, mediaWiki ) ); diff --git a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js index 1db8c61d49..23ef26f6f6 100644 --- a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js @@ -130,7 +130,7 @@ [ '$ 1.50' ], [ '$ 3.00' ], [ '$3.50' ], - // Comma's sort after dots + // Commas sort after dots // Not intentional but test to detect changes [ '€ 2,99' ] ], diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js new file mode 100644 index 0000000000..ae3ebbf742 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js @@ -0,0 +1,39 @@ +( function () { + var byteLength = require( 'mediawiki.String' ).byteLength; + + QUnit.module( 'mediawiki.String.byteLength', QUnit.newMwEnvironment() ); + + QUnit.test( 'Simple text', function ( assert ) { + var azLc = 'abcdefghijklmnopqrstuvwxyz', + azUc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + num = '0123456789', + x = '*', + space = ' '; + + assert.equal( byteLength( azLc ), 26, 'Lowercase a-z' ); + assert.equal( byteLength( azUc ), 26, 'Uppercase A-Z' ); + assert.equal( byteLength( num ), 10, 'Numbers 0-9' ); + assert.equal( byteLength( x ), 1, 'An asterisk' ); + assert.equal( byteLength( space ), 3, '3 spaces' ); + + } ); + + QUnit.test( 'Special text', function ( assert ) { + // https://en.wikipedia.org/wiki/UTF-8 + var u0024 = '$', + // Cent symbol + u00A2 = '\u00A2', + // Euro symbol + u20AC = '\u20AC', + // Character \U00024B62 (Han script) can't be represented in javascript as a single + // code point, instead it is composed as a surrogate pair of two separate code units. + // http://codepoints.net/U+24B62 + // http://www.fileformat.info/info/unicode/char/24B62/index.htm + u024B62 = '\uD852\uDF62'; + + assert.strictEqual( byteLength( u0024 ), 1, 'U+0024' ); + assert.strictEqual( byteLength( u00A2 ), 2, 'U+00A2' ); + assert.strictEqual( byteLength( u20AC ), 3, 'U+20AC' ); + assert.strictEqual( byteLength( u024B62 ), 4, 'U+024B62 (surrogate pair: \\uD852\\uDF62)' ); + } ); +}() ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js new file mode 100644 index 0000000000..e2eea94e84 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js @@ -0,0 +1,150 @@ +( function ( $, mw ) { + var simpleSample, U_20AC, poop, mbSample, + trimByteLength = require( 'mediawiki.String' ).trimByteLength; + + QUnit.module( 'mediawiki.String.trimByteLength', QUnit.newMwEnvironment() ); + + // Simple sample (20 chars, 20 bytes) + simpleSample = '12345678901234567890'; + + // 3 bytes (euro-symbol) + U_20AC = '\u20AC'; + + // Outside of the BMP (pile of poo emoji) + poop = '\uD83D\uDCA9'; // "💩" + + // Multi-byte sample (22 chars, 26 bytes) + mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC; + + /** + * Test factory for mw.String#trimByteLength + * + * @param {Object} options + * @param {string} options.description Test name + * @param {string} options.sample Sequence of characters to trim + * @param {string} [options.initial] Previous value of the sequence of characters, if any + * @param {Number} options.limit Length to trim to + * @param {Function} [options.fn] Filter function + * @param {string} options.expected Expected final value + */ + function byteLimitTest( options ) { + var opt = $.extend( { + description: '', + sample: '', + initial: '', + limit: 0, + fn: function ( a ) { return a; }, + expected: '' + }, options ); + + QUnit.test( opt.description, function ( assert ) { + var res = trimByteLength( opt.initial, opt.sample, opt.limit, opt.fn ); + + assert.equal( + res.newVal, + opt.expected, + 'New value matches the expected string' + ); + } ); + } + + byteLimitTest( { + description: 'Limit using the maxlength attribute', + limit: 10, + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte)', + limit: 14, + sample: mbSample, + expected: '1234567890' + U_20AC + '1' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte, outside BMP)', + limit: 3, + sample: poop, + expected: '' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte) overlapping a byte', + limit: 12, + sample: mbSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + limit: 6, + fn: function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + }, + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + limit: 6, + fn: function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + }, + sample: 'User:Example', + // The callback alters the value to be used to calculeate + // the length. The altered value is "Exampl" which has + // a length of 6, the "e" would exceed the limit. + expected: 'User:Exampl' + } ); + + byteLimitTest( { + description: 'Input filter that increases the length', + limit: 10, + fn: function ( text ) { + return 'prefix' + text; + }, + sample: simpleSample, + // Prefix adds 6 characters, limit is reached after 4 + expected: '1234' + } ); + + byteLimitTest( { + description: 'Trim from insertion when limit exceeded', + limit: 3, + initial: 'abc', + sample: 'zabc', + // Trim from the insertion point (at 0), not the end + expected: 'abc' + } ); + + byteLimitTest( { + description: 'Trim from insertion when limit exceeded', + limit: 3, + initial: 'abc', + sample: 'azbc', + // Trim from the insertion point (at 1), not the end + expected: 'abc' + } ); + + byteLimitTest( { + description: 'Do not cut up false matching substrings in emoji insertions', + limit: 12, + initial: '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩" + sample: '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩" + expected: '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9' // "💩💹💩" + } ); + + byteLimitTest( { + description: 'Unpaired surrogates do not crash', + limit: 4, + sample: '\uD800\uD800\uDFFF', + expected: '\uD800' + } ); + +}( jQuery, mediaWiki ) ); diff --git a/tests/qunit/suites/resources/startup.test.js b/tests/qunit/suites/resources/startup.test.js index 0866b9ecee..6a704b5af8 100644 --- a/tests/qunit/suites/resources/startup.test.js +++ b/tests/qunit/suites/resources/startup.test.js @@ -21,11 +21,8 @@ 'Mozilla/5.0 (Windows NT 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.95 Safari/537.36 OPR/15.0.1147.153', 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36 OPR/16.0.1196.62', 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.125 Safari/537.36 OPR/23.0.1522.75', - // Internet Explorer 10+ - 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)', + // Internet Explorer 11 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko', - // IE Mobile - 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; NOKIA; Lumia 800)', // Edge 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246', // Edge Mobile @@ -107,6 +104,10 @@ blacklisted: [ /* Grade C */ + // Internet Explorer 10 + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)', + // IE Mobile 10 + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; HTC; Windows Phone 8X by HTC)', // PlayStation 'Mozilla/5.0 (PLAYSTATION 3; 1.10)', 'Mozilla/5.0 (PLAYSTATION 3; 3.55)', diff --git a/tests/selenium/README.md b/tests/selenium/README.md index 16283772a0..2dbf27140a 100644 --- a/tests/selenium/README.md +++ b/tests/selenium/README.md @@ -37,14 +37,14 @@ To run only one test (name contains string 'preferences'): The runner reads the config file `wdio.conf.js` and runs the spec listed in `page.js`. -The defaults in the configuration files aim are targetting a MediaWiki-Vagrant -installation on installation on http://127.0.0.1:8080 with a user Admin and -password 'vagrant'. Those settings can be overriden using environment +The defaults in the configuration files aim are targeting a MediaWiki-Vagrant +installation on http://127.0.0.1:8080 with a user Admin and +password 'vagrant'. Those settings can be overridden using environment variables: `MW_SERVER`: to be set to the value of your $wgServer -`MW_SCRIPT_PATH`: ditto with $wgScriptPath -`MEDIAWIKI_USER`: username of an account that can create users on the wiki. +`MW_SCRIPT_PATH`: ditto with $wgScriptPath +`MEDIAWIKI_USER`: username of an account that can create users on the wiki `MEDIAWIKI_PASSWORD`: password for above user Example: