Merge "Introduce UserOptionsManager and DefaultOptionsManager"

This commit is contained in:
jenkins-bot 2020-05-01 20:22:56 +00:00 committed by Gerrit Code Review
commit 3cfaa194ed
19 changed files with 1301 additions and 406 deletions

View file

@ -989,6 +989,10 @@ because of Phabricator reports.
* wfGetScriptUrl() was deprecated. The script URL should be configured rather
than detected. wfScript() can be used to get a configured script URL.
* Action::factory() with null $action argument is hard deprecated
* The following methods of the User class were deprecated: getDefaultOptions,
getDefaultOption, getOptions, getOption, getBoolOption, getIntOption,
setOption, listOptionKinds, getOptionKinds, resetOptions. Use corresponding
methods in UserOptionsLookup or UserOptionsManager service classes instead.
* …
=== Other changes in 1.35 ===

View file

@ -62,6 +62,8 @@ use MediaWiki\Storage\NameTableStore;
use MediaWiki\Storage\NameTableStoreFactory;
use MediaWiki\Storage\PageEditStash;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
use MessageCache;
use MimeAnalyzer;
use MWException;
@ -1218,6 +1220,22 @@ class MediaWikiServices extends ServiceContainer {
return $this->getService( 'UserNameUtils' );
}
/**
* @since 1.35
* @return UserOptionsLookup
*/
public function getUserOptionsLookup() : UserOptionsLookup {
return $this->getService( 'UserOptionsLookup' );
}
/**
* @since 1.35
* @return UserOptionsManager
*/
public function getUserOptionsManager() : UserOptionsManager {
return $this->getService( 'UserOptionsManager' );
}
/**
* @since 1.28
* @return VirtualRESTServiceClient

View file

@ -92,7 +92,10 @@ use MediaWiki\Storage\BlobStoreFactory;
use MediaWiki\Storage\NameTableStoreFactory;
use MediaWiki\Storage\PageEditStash;
use MediaWiki\Storage\SqlBlobStore;
use MediaWiki\User\DefaultOptionsManager;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
use Wikimedia\DependencyStore\KeyValueDependencyStore;
use Wikimedia\DependencyStore\SqlModuleDependencyStore;
use Wikimedia\Message\IMessageFormatterFactory;
@ -1103,6 +1106,20 @@ return [
);
},
'UserOptionsLookup' => function ( MediaWikiServices $services ) : UserOptionsLookup {
return $services->getUserOptionsManager();
},
'UserOptionsManager' => function ( MediaWikiServices $services ) : UserOptionsManager {
return new UserOptionsManager(
new ServiceOptions( UserOptionsManager::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
$services->get( '_DefaultOptionsManager' ),
$services->getLanguageConverterFactory(),
$services->getDBLoadBalancer(),
LoggerFactory::getInstance( 'UserOptionsManager' )
);
},
'VirtualRESTServiceClient' =>
function ( MediaWikiServices $services ) : VirtualRESTServiceClient {
$config = $services->getMainConfig()->get( 'VirtualRestConfig' );
@ -1162,6 +1179,13 @@ return [
);
},
'_DefaultOptionsManager' => function ( MediaWikiServices $services ) : DefaultOptionsManager {
return new DefaultOptionsManager(
new ServiceOptions( DefaultOptionsManager::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
$services->getContentLanguage()
);
},
'_MediaWikiTitleCodec' => function ( MediaWikiServices $services ) : MediaWikiTitleCodec {
return new MediaWikiTitleCodec(
$services->getContentLanguage(),

View file

@ -437,8 +437,14 @@ abstract class Installer {
$mwServices->redefineService( 'InterwikiLookup', function () {
return new NullInterwikiLookup();
} );
// Disable user options database fetching, only rely on default options.
$mwServices->redefineService(
'UserOptionsLookup',
function ( MediaWikiServices $services ) {
return $services->get( '_DefaultOptionsManager' );
}
);
// Having a user with id = 0 safeguards us from DB access via User::loadOptions().
$wgUser = User::newFromId( 0 );
RequestContext::getMain()->setUser( $wgUser );
@ -1549,6 +1555,7 @@ abstract class Installer {
[ 'name' => 'stats', 'callback' => [ $this, 'populateSiteStats' ] ],
[ 'name' => 'keys', 'callback' => [ $this, 'generateKeys' ] ],
[ 'name' => 'updates', 'callback' => [ $installer, 'insertUpdateKeys' ] ],
[ 'name' => 'restore-services', 'callback' => [ $this, 'restoreServices' ] ],
[ 'name' => 'sysop', 'callback' => [ $this, 'createSysop' ] ],
[ 'name' => 'mainpage', 'callback' => [ $this, 'createMainpage' ] ],
];
@ -1641,6 +1648,21 @@ abstract class Installer {
return $this->doGenerateKeys( $keys );
}
/**
* Restore services that have been redefined in the early stage of installation
* @return Status
*/
public function restoreServices() {
MediaWikiServices::resetGlobalInstance();
MediaWikiServices::getInstance()->redefineService(
'UserOptionsLookup',
function ( MediaWikiServices $services ) {
return $services->get( 'UserOptionsManager' );
}
);
return Status::newGood();
}
/**
* Generate a secret value for variables using a secure generator.
*

View file

@ -258,6 +258,7 @@
"config-install-user-grant-failed": "Granting permission to user \"$1\" failed: $2",
"config-install-user-missing": "The specified user \"$1\" does not exist.",
"config-install-user-missing-create": "The specified user \"$1\" does not exist.\nPlease click the \"create account\" checkbox below if you want to create it.",
"config-install-restore-services": "Restoring mediawiki services",
"config-install-tables": "Creating tables",
"config-install-tables-exist": "<strong>Warning:</strong> MediaWiki tables seem to already exist.\nSkipping creation.",
"config-install-tables-failed": "<strong>Error:</strong> Table creation failed with the following error: $1",

View file

@ -282,6 +282,7 @@
"config-install-user-grant-failed": "Parameters:\n* $1 is the database username for which granting rights failed\n* $2 is the error message",
"config-install-user-missing": "Used as PostgreSQL error message. Parameters:\n* $1 - database username\nSee also:\n* {{msg-mw|Config-install-user-missing-create}}",
"config-install-user-missing-create": "Used as PostgreSQL error message. Parameters:\n* $1 - database username\nSee also:\n* {{msg-mw|Config-install-user-missing}}",
"config-install-restore-services": "Message indicates that MediaWiki services overridden during installation are being restored",
"config-install-tables": "Message indicates that the tables are being created\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-updates}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
"config-install-tables-exist": "Error notice during the installation saying that the database already seems set up for MediaWiki, so it's continuing without taking that step.",
"config-install-tables-failed": "Used as PostgreSQL error message. Parameters:\n* $1 - detailed error message",

View file

@ -0,0 +1,140 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
use Hooks;
use Language;
use LanguageConverter;
use MediaWiki\Config\ServiceOptions;
use Skin;
use Wikimedia\Assert\Assert;
/**
* A service class to control default user options
* @since 1.35
*/
class DefaultOptionsManager extends UserOptionsLookup {
public const CONSTRUCTOR_OPTIONS = [
'DefaultSkin',
'DefaultUserOptions',
'NamespacesToBeSearchedDefault'
];
/** @var ServiceOptions */
private $serviceOptions;
/** @var Language */
private $contentLang;
/** @var array|null Cached default options */
private $defaultOptions = null;
/**
* @param ServiceOptions $options
* @param Language $contentLang
*/
public function __construct(
ServiceOptions $options,
Language $contentLang
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->serviceOptions = $options;
$this->contentLang = $contentLang;
}
/**
* @inheritDoc
*/
public function getDefaultOptions(): array {
if ( $this->defaultOptions !== null ) {
return $this->defaultOptions;
}
$this->defaultOptions = $this->serviceOptions->get( 'DefaultUserOptions' );
// Default language setting
$this->defaultOptions['language'] = $this->contentLang->getCode();
foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
if ( $langCode === $this->contentLang->getCode() ) {
$this->defaultOptions['variant'] = $langCode;
} else {
$this->defaultOptions["variant-$langCode"] = $langCode;
}
}
// NOTE: don't use SearchEngineConfig::getSearchableNamespaces here,
// since extensions may change the set of searchable namespaces depending
// on user groups/permissions.
foreach ( $this->serviceOptions->get( 'NamespacesToBeSearchedDefault' ) as $nsnum => $val ) {
$this->defaultOptions['searchNs' . $nsnum] = (bool)$val;
}
$this->defaultOptions['skin'] = Skin::normalizeKey( $this->serviceOptions->get( 'DefaultSkin' ) );
Hooks::run( 'UserGetDefaultOptions', [ &$this->defaultOptions ] );
return $this->defaultOptions;
}
/**
* @inheritDoc
*/
public function getDefaultOption( string $opt ) {
$defOpts = $this->getDefaultOptions();
return $defOpts[$opt] ?? null;
}
/**
* @inheritDoc
*/
public function getOption(
UserIdentity $user,
string $oname,
$defaultOverride = null,
bool $ignoreHidden = false
) {
$this->verifyUsable( $user, __METHOD__ );
return $this->getDefaultOption( $oname ) ?? $defaultOverride;
}
/**
* @inheritDoc
*/
public function getOptions( UserIdentity $user, int $flags = 0 ): array {
$this->verifyUsable( $user, __METHOD__ );
if ( $flags & self::EXCLUDE_DEFAULTS ) {
return [];
}
return $this->getDefaultOptions();
}
/**
* Checks if the DefaultOptionsManager is usable as an instance of UserOptionsLookup.
* It only makes sense in an installer context when UserOptionsManager cannot be yet instantiated
* as the database is not available. Thus, this can only be called for an anon user,
* calling under different circumstances indicates a bug.
* @param UserIdentity $user
* @param string $fname
*/
private function verifyUsable( UserIdentity $user, string $fname ) {
Assert::precondition( !$user->isRegistered(), "$fname called on a registered user " );
}
}

View file

@ -33,7 +33,7 @@ use MediaWiki\Session\SessionManager;
use MediaWiki\Session\Token;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use Wikimedia\Assert\Assert;
use MediaWiki\User\UserOptionsLookup;
use Wikimedia\IPSet;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\Database;
@ -67,13 +67,14 @@ class User implements IDBAccessObject, UserIdentity {
* Version number to tag cached versions of serialized User objects. Should be increased when
* {@link $mCacheVars} or one of it's members changes.
*/
const VERSION = 14;
const VERSION = 15;
/**
* Exclude user options that are set to their default value.
* @deprecated since 1.35 Use UserOptionsLookup::EXCLUDE_DEFAULTS
* @since 1.25
*/
const GETOPTIONS_EXCLUDE_DEFAULTS = 1;
const GETOPTIONS_EXCLUDE_DEFAULTS = UserOptionsLookup::EXCLUDE_DEFAULTS;
/**
* @since 1.27
@ -107,8 +108,6 @@ class User implements IDBAccessObject, UserIdentity {
'mEditCount',
// user_groups table
'mGroupMemberships',
// user_properties table
'mOptionOverrides',
// actor table
'mActorId',
];
@ -144,16 +143,9 @@ class User implements IDBAccessObject, UserIdentity {
protected $mEditCount;
/** @var UserGroupMembership[] Associative array of (group name => UserGroupMembership object) */
protected $mGroupMemberships;
/** @var array */
protected $mOptionOverrides;
// @}
// @{
/**
* @var bool Whether the cache variables have been loaded.
*/
public $mOptionsLoaded;
/**
* @var array|bool Array with already loaded items or true if all items have been loaded.
*/
@ -211,8 +203,6 @@ class User implements IDBAccessObject, UserIdentity {
* @var bool
*/
public $mHideName;
/** @var array */
public $mOptions;
/** @var WebRequest */
private $mRequest;
@ -263,6 +253,10 @@ class User implements IDBAccessObject, UserIdentity {
if ( $name === 'mRights' ) {
$copy = $this->getRights();
return $copy;
} elseif ( $name === 'mOptions' ) {
wfDeprecated( 'User::$mOptions', '1.35' );
$options = $this->getOptions();
return $options;
} elseif ( !property_exists( $this, $name ) ) {
// T227688 - do not break $u->foo['bar'] = 1
wfLogWarning( 'tried to get non-existent property' );
@ -283,6 +277,12 @@ class User implements IDBAccessObject, UserIdentity {
$this,
$value === null ? [] : $value
);
} elseif ( $name === 'mOptions' ) {
wfDeprecated( 'User::$mOptions', '1.35' );
MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $this );
foreach ( $value as $key => $val ) {
$this->setOption( $key, $val );
}
} elseif ( !property_exists( $this, $name ) ) {
$this->$name = $value;
} else {
@ -481,7 +481,6 @@ class User implements IDBAccessObject, UserIdentity {
$this->loadFromDatabase( self::READ_NORMAL );
$this->loadGroups();
$this->loadOptions();
$data = [];
foreach ( self::$mCacheVars as $name ) {
@ -1163,8 +1162,6 @@ class User implements IDBAccessObject, UserIdentity {
$this->mActorId = $actorId;
$this->mRealName = '';
$this->mEmail = '';
$this->mOptionOverrides = null;
$this->mOptionsLoaded = false;
$loggedOut = $this->mRequest && !defined( 'MW_NO_SESSION' )
? $this->mRequest->getSession()->getLoggedOutTimestamp() : 0;
@ -1409,7 +1406,9 @@ class User implements IDBAccessObject, UserIdentity {
}
}
if ( isset( $data['user_properties'] ) && is_array( $data['user_properties'] ) ) {
$this->loadOptions( $data['user_properties'] );
MediaWikiServices::getInstance()
->getUserOptionsManager()
->loadUserOptions( $this, $this->queryFlagsUsed, $data['user_properties'] );
}
}
}
@ -1570,8 +1569,6 @@ class User implements IDBAccessObject, UserIdentity {
$this->mEffectiveGroups = null;
$this->mImplicitGroups = null;
$this->mGroupMemberships = null;
$this->mOptions = null;
$this->mOptionsLoaded = false;
$this->mEditCount = null;
// Replacement of former `$this->mRights = null` line
@ -1579,6 +1576,7 @@ class User implements IDBAccessObject, UserIdentity {
MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(
$this
);
MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $this );
}
if ( $reloadFrom ) {
@ -1587,76 +1585,30 @@ class User implements IDBAccessObject, UserIdentity {
}
}
/** @var array|null */
private static $defOpt = null;
/** @var string|null */
private static $defOptLang = null;
/**
* Reset the process cache of default user options. This is only necessary
* if the wiki configuration has changed since defaults were calculated,
* and as such should only be performed inside the testing suite that
* regularly changes wiki configuration.
*/
public static function resetGetDefaultOptionsForTestsOnly() {
Assert::invariant( defined( 'MW_PHPUNIT_TEST' ), 'Unit tests only' );
self::$defOpt = null;
self::$defOptLang = null;
}
/**
* Combine the language default options with any site-specific options
* and add the default language variants.
*
* @deprecated since 1.35 Use UserOptionsLookup::getDefaultOptions instead.
* @return array Array of options; typically strings, possibly booleans
*/
public static function getDefaultOptions() {
global $wgNamespacesToBeSearchedDefault,
$wgDefaultUserOptions,
$wgDefaultSkin;
$contLang = MediaWikiServices::getInstance()->getContentLanguage();
if ( self::$defOpt !== null && self::$defOptLang === $contLang->getCode() ) {
// The content language does not change (and should not change) mid-request, but the
// unit tests change it anyway, and expect this method to return values relevant to the
// current content language.
return self::$defOpt;
}
self::$defOpt = $wgDefaultUserOptions;
// Default language setting
self::$defOptLang = $contLang->getCode();
self::$defOpt['language'] = self::$defOptLang;
foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
if ( $langCode === $contLang->getCode() ) {
self::$defOpt['variant'] = $langCode;
} else {
self::$defOpt["variant-$langCode"] = $langCode;
}
}
// NOTE: don't use SearchEngineConfig::getSearchableNamespaces here,
// since extensions may change the set of searchable namespaces depending
// on user groups/permissions.
foreach ( $wgNamespacesToBeSearchedDefault as $nsnum => $val ) {
self::$defOpt['searchNs' . $nsnum] = (bool)$val;
}
self::$defOpt['skin'] = Skin::normalizeKey( $wgDefaultSkin );
Hooks::run( 'UserGetDefaultOptions', [ &self::$defOpt ] );
return self::$defOpt;
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getDefaultOptions();
}
/**
* Get a given default option value.
*
* @deprecated since 1.35 Use UserOptionsLookup::getDefaultOption instead.
* @param string $opt Name of option to retrieve
* @return string|null Default option value
*/
public static function getDefaultOption( $opt ) {
$defOpts = self::getDefaultOptions();
return $defOpts[$opt] ?? null;
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getDefaultOption( $opt );
}
/**
@ -2901,25 +2853,15 @@ class User implements IDBAccessObject, UserIdentity {
* @return mixed|null User's current value for the option
* @see getBoolOption()
* @see getIntOption()
* @deprecated since 1.35 Use UserOptionsLookup::getOption instead
*/
public function getOption( $oname, $defaultOverride = null, $ignoreHidden = false ) {
global $wgHiddenPrefs;
$this->loadOptions();
# We want 'disabled' preferences to always behave as the default value for
# users, even if they have set the option explicitly in their settings (ie they
# set it, and then it was disabled removing their ability to change it). But
# we don't want to erase the preferences in the database in case the preference
# is re-enabled again. So don't touch $mOptions, just override the returned value
if ( !$ignoreHidden && in_array( $oname, $wgHiddenPrefs ) ) {
return self::getDefaultOption( $oname );
if ( $oname === null ) {
return null; // b/c
}
if ( array_key_exists( $oname, $this->mOptions ) ) {
return $this->mOptions[$oname];
}
return $defaultOverride;
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getOption( $this, $oname, $defaultOverride, $ignoreHidden );
}
/**
@ -2929,29 +2871,12 @@ class User implements IDBAccessObject, UserIdentity {
* User::GETOPTIONS_EXCLUDE_DEFAULTS Exclude user options that are set
* to the default value. (Since 1.25)
* @return array
* @deprecated since 1.35 Use UserOptionsLookup::getOptions instead
*/
public function getOptions( $flags = 0 ) {
global $wgHiddenPrefs;
$this->loadOptions();
$options = $this->mOptions;
# We want 'disabled' preferences to always behave as the default value for
# users, even if they have set the option explicitly in their settings (ie they
# set it, and then it was disabled removing their ability to change it). But
# we don't want to erase the preferences in the database in case the preference
# is re-enabled again. So don't touch $mOptions, just override the returned value
foreach ( $wgHiddenPrefs as $pref ) {
$default = self::getDefaultOption( $pref );
if ( $default !== null ) {
$options[$pref] = $default;
}
}
if ( $flags & self::GETOPTIONS_EXCLUDE_DEFAULTS ) {
$options = array_diff_assoc( $options, self::getDefaultOptions() );
}
return $options;
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getOptions( $this, $flags );
}
/**
@ -2960,9 +2885,12 @@ class User implements IDBAccessObject, UserIdentity {
* @param string $oname The option to check
* @return bool User's current value for the option
* @see getOption()
* @deprecated since 1.35 Use UserOptionsLookup::getBoolOption instead
*/
public function getBoolOption( $oname ) {
return (bool)$this->getOption( $oname );
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getBoolOption( $this, $oname );
}
/**
@ -2972,13 +2900,15 @@ class User implements IDBAccessObject, UserIdentity {
* @param int $defaultOverride A default value returned if the option does not exist
* @return int User's current value for the option
* @see getOption()
* @deprecated since 1.35 Use UserOptionsLookup::getIntOption instead
*/
public function getIntOption( $oname, $defaultOverride = 0 ) {
$val = $this->getOption( $oname );
if ( $val == '' ) {
$val = $defaultOverride;
if ( $oname === null ) {
return null; // b/c
}
return intval( $val );
return MediaWikiServices::getInstance()
->getUserOptionsLookup()
->getIntOption( $this, $oname, $defaultOverride );
}
/**
@ -2988,16 +2918,12 @@ class User implements IDBAccessObject, UserIdentity {
*
* @param string $oname The option to set
* @param mixed $val New value to set
* @deprecated since 1.35 Use UserOptionsManager::setOption instead
*/
public function setOption( $oname, $val ) {
$this->loadOptions();
// Explicitly NULL values should refer to defaults
if ( $val === null ) {
$val = self::getDefaultOption( $oname );
}
$this->mOptions[$oname] = $val;
MediaWikiServices::getInstance()
->getUserOptionsManager()
->setOption( $this, $oname, $val );
}
/**
@ -3071,16 +2997,12 @@ class User implements IDBAccessObject, UserIdentity {
*
* @see User::getOptionKinds
* @return array Option kinds
* @deprecated since 1.35 Use UserOptionsManager::listOptionKinds instead
*/
public static function listOptionKinds() {
return [
'registered',
'registered-multiselect',
'registered-checkmatrix',
'userjs',
'special',
'unused'
];
return MediaWikiServices::getInstance()
->getUserOptionsManager()
->listOptionKinds();
}
/**
@ -3094,76 +3016,12 @@ class User implements IDBAccessObject, UserIdentity {
* @param array|null $options Assoc. array with options keys to check as keys.
* Defaults to $this->mOptions.
* @return array The key => kind mapping data
* @deprecated since 1.35 Use UserOptionsManager::getOptionKinds instead
*/
public function getOptionKinds( IContextSource $context, $options = null ) {
$this->loadOptions();
if ( $options === null ) {
$options = $this->mOptions;
}
$preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
$prefs = $preferencesFactory->getFormDescriptor( $this, $context );
$mapping = [];
// Pull out the "special" options, so they don't get converted as
// multiselect or checkmatrix.
$specialOptions = array_fill_keys( $preferencesFactory->getSaveBlacklist(), true );
foreach ( $specialOptions as $name => $value ) {
unset( $prefs[$name] );
}
// Multiselect and checkmatrix options are stored in the database with
// one key per option, each having a boolean value. Extract those keys.
$multiselectOptions = [];
foreach ( $prefs as $name => $info ) {
if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
( isset( $info['class'] ) && $info['class'] == HTMLMultiSelectField::class ) ) {
$opts = HTMLFormField::flattenOptions( $info['options'] );
$prefix = $info['prefix'] ?? $name;
foreach ( $opts as $value ) {
$multiselectOptions["$prefix$value"] = true;
}
unset( $prefs[$name] );
}
}
$checkmatrixOptions = [];
foreach ( $prefs as $name => $info ) {
if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class ) ) {
$columns = HTMLFormField::flattenOptions( $info['columns'] );
$rows = HTMLFormField::flattenOptions( $info['rows'] );
$prefix = $info['prefix'] ?? $name;
foreach ( $columns as $column ) {
foreach ( $rows as $row ) {
$checkmatrixOptions["$prefix$column-$row"] = true;
}
}
unset( $prefs[$name] );
}
}
// $value is ignored
foreach ( $options as $key => $value ) {
if ( isset( $prefs[$key] ) ) {
$mapping[$key] = 'registered';
} elseif ( isset( $multiselectOptions[$key] ) ) {
$mapping[$key] = 'registered-multiselect';
} elseif ( isset( $checkmatrixOptions[$key] ) ) {
$mapping[$key] = 'registered-checkmatrix';
} elseif ( isset( $specialOptions[$key] ) ) {
$mapping[$key] = 'special';
} elseif ( substr( $key, 0, 7 ) === 'userjs-' ) {
$mapping[$key] = 'userjs';
} else {
$mapping[$key] = 'unused';
}
}
return $mapping;
return MediaWikiServices::getInstance()
->getUserOptionsManager()
->getOptionKinds( $this, $context, $options );
}
/**
@ -3179,46 +3037,19 @@ class User implements IDBAccessObject, UserIdentity {
* @param IContextSource|null $context Context source used when $resetKinds
* does not contain 'all', passed to getOptionKinds().
* Defaults to RequestContext::getMain() when null.
* @deprecated since 1.35 Use UserOptionsManager::resetOptions instead.
*/
public function resetOptions(
$resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ],
IContextSource $context = null
) {
$this->load();
$defaultOptions = self::getDefaultOptions();
if ( !is_array( $resetKinds ) ) {
$resetKinds = [ $resetKinds ];
}
if ( in_array( 'all', $resetKinds ) ) {
$newOptions = $defaultOptions;
} else {
if ( $context === null ) {
$context = RequestContext::getMain();
}
$optionKinds = $this->getOptionKinds( $context );
$resetKinds = array_intersect( $resetKinds, self::listOptionKinds() );
$newOptions = [];
// Use default values for the options that should be deleted, and
// copy old values for the ones that shouldn't.
foreach ( $this->mOptions as $key => $value ) {
if ( in_array( $optionKinds[$key], $resetKinds ) ) {
if ( array_key_exists( $key, $defaultOptions ) ) {
$newOptions[$key] = $defaultOptions[$key];
}
} else {
$newOptions[$key] = $value;
}
}
}
Hooks::run( 'UserResetAllOptions', [ $this, &$newOptions, $this->mOptions, $resetKinds ] );
$this->mOptions = $newOptions;
$this->mOptionsLoaded = true;
MediaWikiServices::getInstance()
->getUserOptionsManager()
->resetOptions(
$this,
$context ?? RequestContext::getMain(),
$resetKinds
);
}
/**
@ -3990,7 +3821,7 @@ class User implements IDBAccessObject, UserIdentity {
} );
$this->mTouched = $newTouched;
$this->saveOptions();
MediaWikiServices::getInstance()->getUserOptionsManager()->saveOptions( $this );
Hooks::run( 'UserSaveSettings', [ $this ] );
$this->clearSharedCache( 'changed' );
@ -4051,7 +3882,9 @@ class User implements IDBAccessObject, UserIdentity {
$user->load();
$user->setToken(); // init token
if ( isset( $params['options'] ) ) {
$user->mOptions = $params['options'] + (array)$user->mOptions;
MediaWikiServices::getInstance()
->getUserOptionsManager()
->loadUserOptions( $user, $user->queryFlagsUsed, $params['options'] );
unset( $params['options'] );
}
$dbw = wfGetDB( DB_MASTER );
@ -4177,7 +4010,7 @@ class User implements IDBAccessObject, UserIdentity {
// Clear instance cache other than user table data and actor, which is already accurate
$this->clearInstanceCache();
$this->saveOptions();
MediaWikiServices::getInstance()->getUserOptionsManager()->saveOptions( $this );
return Status::newGood();
}
@ -5074,149 +4907,6 @@ class User implements IDBAccessObject, UserIdentity {
return true; // disabled
}
/**
* Load the user options either from cache, the database or an array
*
* @param array|null $data Rows for the current user out of the user_properties table
*/
protected function loadOptions( $data = null ) {
$this->load();
if ( $this->mOptionsLoaded ) {
return;
}
$this->mOptions = self::getDefaultOptions();
if ( !$this->getId() ) {
// For unlogged-in users, load language/variant options from request.
// There's no need to do it for logged-in users: they can set preferences,
// and handling of page content is done by $pageLang->getPreferredVariant() and such,
// so don't override user's choice (especially when the user chooses site default).
$factory = MediaWikiServices::getInstance()->getLanguageConverterFactory();
$variant = $factory->getLanguageConverter()->getDefaultVariant();
$this->mOptions['variant'] = $variant;
$this->mOptions['language'] = $variant;
$this->mOptionsLoaded = true;
return;
}
// Maybe load from the object
if ( $this->mOptionOverrides !== null ) {
wfDebug( "User: loading options for user " . $this->getId() . " from override cache.\n" );
foreach ( $this->mOptionOverrides as $key => $value ) {
$this->mOptions[$key] = $value;
}
} else {
if ( !is_array( $data ) ) {
wfDebug( "User: loading options for user " . $this->getId() . " from database.\n" );
// Load from database
$dbr = ( $this->queryFlagsUsed & self::READ_LATEST )
? wfGetDB( DB_MASTER )
: wfGetDB( DB_REPLICA );
$res = $dbr->select(
'user_properties',
[ 'up_property', 'up_value' ],
[ 'up_user' => $this->getId() ],
__METHOD__
);
$this->mOptionOverrides = [];
$data = [];
foreach ( $res as $row ) {
// Convert '0' to 0. PHP's boolean conversion considers them both
// false, but e.g. JavaScript considers the former as true.
// @todo: T54542 Somehow determine the desired type (string/int/bool)
// and convert all values here.
if ( $row->up_value === '0' ) {
$row->up_value = 0;
}
$data[$row->up_property] = $row->up_value;
}
}
foreach ( $data as $property => $value ) {
$this->mOptionOverrides[$property] = $value;
$this->mOptions[$property] = $value;
}
}
// Replace deprecated language codes
$this->mOptions['language'] = LanguageCode::replaceDeprecatedCodes(
$this->mOptions['language']
);
$this->mOptionsLoaded = true;
Hooks::run( 'UserLoadOptions', [ $this, &$this->mOptions ] );
}
/**
* Saves the non-default options for this user, as previously set e.g. via
* setOption(), in the database's "user_properties" (preferences) table.
* Usually used via saveSettings().
*/
protected function saveOptions() {
$this->loadOptions();
// Not using getOptions(), to keep hidden preferences in database
$saveOptions = $this->mOptions;
// Allow hooks to abort, for instance to save to a global profile.
// Reset options to default state before saving.
if ( !Hooks::run( 'UserSaveOptions', [ $this, &$saveOptions ] ) ) {
return;
}
$userId = $this->getId();
$insert_rows = []; // all the new preference rows
foreach ( $saveOptions as $key => $value ) {
// Don't bother storing default values
$defaultOption = self::getDefaultOption( $key );
if ( ( $defaultOption === null && $value !== false && $value !== null )
|| $value != $defaultOption
) {
$insert_rows[] = [
'up_user' => $userId,
'up_property' => $key,
'up_value' => $value,
];
}
}
$dbw = wfGetDB( DB_MASTER );
$res = $dbw->select( 'user_properties',
[ 'up_property', 'up_value' ], [ 'up_user' => $userId ], __METHOD__ );
// Find prior rows that need to be removed or updated. These rows will
// all be deleted (the latter so that INSERT IGNORE applies the new values).
$keysDelete = [];
foreach ( $res as $row ) {
if ( !isset( $saveOptions[$row->up_property] )
|| strcmp( $saveOptions[$row->up_property], $row->up_value ) != 0
) {
$keysDelete[] = $row->up_property;
}
}
if ( count( $keysDelete ) ) {
// Do the DELETE by PRIMARY KEY for prior rows.
// In the past a very large portion of calls to this function are for setting
// 'rememberpassword' for new accounts (a preference that has since been removed).
// Doing a blanket per-user DELETE for new accounts with no rows in the table
// caused gap locks on [max user ID,+infinity) which caused high contention since
// updates would pile up on each other as they are for higher (newer) user IDs.
// It might not be necessary these days, but it shouldn't hurt either.
$dbw->delete( 'user_properties',
[ 'up_user' => $userId, 'up_property' => $keysDelete ], __METHOD__ );
}
// Insert the new preference rows
$dbw->insert( 'user_properties', $insert_rows, __METHOD__, [ 'IGNORE' ] );
}
/**
* Return the list of user fields that should be selected to create
* a new user object.
@ -5340,5 +5030,4 @@ class User implements IDBAccessObject, UserIdentity {
public function isAllowUsertalk() {
return $this->mAllowUsertalk;
}
}

View file

@ -0,0 +1,107 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
/**
* Provides access to user options
* @since 1.35
*/
abstract class UserOptionsLookup {
/**
* Exclude user options that are set to their default value.
*/
public const EXCLUDE_DEFAULTS = 1;
/**
* Combine the language default options with any site-specific options
* and add the default language variants.
*
* @return array Array of String options
*/
abstract public function getDefaultOptions(): array;
/**
* Get a given default option value.
*
* @param string $opt Name of option to retrieve
* @return string|null Default option value
*/
abstract public function getDefaultOption( string $opt );
/**
* Get the user's current setting for a given option.
*
* @param UserIdentity $user The user to get the option for
* @param string $oname The option to check
* @param mixed|null $defaultOverride A default value returned if the option does not exist
* @param bool $ignoreHidden Whether to ignore the effects of $wgHiddenPrefs
* @return mixed|null User's current value for the option
* @see getBoolOption()
* @see getIntOption()
*/
abstract public function getOption(
UserIdentity $user,
string $oname,
$defaultOverride = null,
bool $ignoreHidden = false
);
/**
* Get all user's options
*
* @param UserIdentity $user The user to get the option for
* @param int $flags Bitwise combination of:
* UserOptionsManager::EXCLUDE_DEFAULTS Exclude user options that are set
* to the default value.
* @return array
*/
abstract public function getOptions( UserIdentity $user, int $flags = 0 ): array;
/**
* Get the user's current setting for a given option, as a boolean value.
*
* @param UserIdentity $user The user to get the option for
* @param string $oname The option to check
* @return bool User's current value for the option
* @see getOption()
*/
public function getBoolOption( UserIdentity $user, string $oname ): bool {
return (bool)$this->getOption( $user, $oname );
}
/**
* Get the user's current setting for a given option, as an integer value.
*
* @param UserIdentity $user The user to get the option for
* @param string $oname The option to check
* @param int $defaultOverride A default value returned if the option does not exist
* @return int User's current value for the option
* @see getOption()
*/
public function getIntOption( UserIdentity $user, string $oname, int $defaultOverride = 0 ): int {
$val = $this->getOption( $user, $oname );
if ( $val == '' ) {
$val = $defaultOverride;
}
return intval( $val );
}
}

View file

@ -0,0 +1,556 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
use DBAccessObjectUtils;
use Hooks;
use HTMLCheckMatrix;
use HTMLFormField;
use HTMLMultiSelectField;
use IContextSource;
use IDBAccessObject;
use LanguageCode;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MediaWikiServices;
use Psr\Log\LoggerInterface;
use User;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* A service class to control user options
* @since 1.35
*/
class UserOptionsManager extends UserOptionsLookup implements IDBAccessObject {
public const CONSTRUCTOR_OPTIONS = [
'HiddenPrefs'
];
/** @var ServiceOptions */
private $serviceOptions;
/** @var DefaultOptionsManager */
private $defaultOptionsManager;
/** @var LanguageConverterFactory */
private $languageConverterFactory;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var LoggerInterface */
private $logger;
/** @var array Cached options by user */
private $optionsCache = [];
/** @var array Cached original user options fetched from database */
private $originalOptionsCache = [];
/**
* UserOptionsManager constructor.
* @param ServiceOptions $options
* @param DefaultOptionsManager $defaultOptionsManager
* @param LanguageConverterFactory $languageConverterFactory
* @param ILoadBalancer $loadBalancer
* @param LoggerInterface $logger
*/
public function __construct(
ServiceOptions $options,
DefaultOptionsManager $defaultOptionsManager,
LanguageConverterFactory $languageConverterFactory,
ILoadBalancer $loadBalancer,
LoggerInterface $logger
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->serviceOptions = $options;
$this->defaultOptionsManager = $defaultOptionsManager;
$this->languageConverterFactory = $languageConverterFactory;
$this->loadBalancer = $loadBalancer;
$this->logger = $logger;
}
/**
* @inheritDoc
*/
public function getDefaultOptions(): array {
return $this->defaultOptionsManager->getDefaultOptions();
}
/**
* @inheritDoc
*/
public function getDefaultOption( string $opt ) {
return $this->defaultOptionsManager->getDefaultOption( $opt );
}
/**
* @inheritDoc
*/
public function getOption(
UserIdentity $user,
string $oname,
$defaultOverride = null,
bool $ignoreHidden = false
) {
# We want 'disabled' preferences to always behave as the default value for
# users, even if they have set the option explicitly in their settings (ie they
# set it, and then it was disabled removing their ability to change it). But
# we don't want to erase the preferences in the database in case the preference
# is re-enabled again. So don't touch $mOptions, just override the returned value
if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( 'HiddenPrefs' ) ) ) {
return $this->defaultOptionsManager->getDefaultOption( $oname );
}
$options = $this->loadUserOptions( $user );
if ( array_key_exists( $oname, $options ) ) {
return $options[$oname];
}
return $defaultOverride;
}
/**
* @inheritDoc
*/
public function getOptions( UserIdentity $user, int $flags = 0 ): array {
$options = $this->loadUserOptions( $user );
# We want 'disabled' preferences to always behave as the default value for
# users, even if they have set the option explicitly in their settings (ie they
# set it, and then it was disabled removing their ability to change it). But
# we don't want to erase the preferences in the database in case the preference
# is re-enabled again. So don't touch $mOptions, just override the returned value
foreach ( $this->serviceOptions->get( 'HiddenPrefs' ) as $pref ) {
$default = $this->defaultOptionsManager->getDefaultOption( $pref );
if ( $default !== null ) {
$options[$pref] = $default;
}
}
if ( $flags & self::EXCLUDE_DEFAULTS ) {
$options = array_diff_assoc( $options, $this->defaultOptionsManager->getDefaultOptions() );
}
return $options;
}
/**
* Set the given option for a user.
*
* You need to call saveOptions() to actually write to the database.
*
* @param UserIdentity $user
* @param string $oname The option to set
* @param mixed $val New value to set
*/
public function setOption( UserIdentity $user, string $oname, $val ) {
$this->loadUserOptions( $user );
// Explicitly NULL values should refer to defaults
if ( $val === null ) {
$val = $this->defaultOptionsManager->getDefaultOption( $oname );
}
$userKey = $this->getCacheKey( $user );
$this->optionsCache[$userKey][$oname] = $val;
}
/**
* Reset certain (or all) options to the site defaults
*
* The optional parameter determines which kinds of preferences will be reset.
* Supported values are everything that can be reported by getOptionKinds()
* and 'all', which forces a reset of *all* preferences and overrides everything else.
*
* @param UserIdentity $user
* @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
* @param array|string $resetKinds Which kinds of preferences to reset.
* Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
*/
public function resetOptions(
UserIdentity $user,
IContextSource $context,
$resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
) {
$oldOptions = $this->loadUserOptions( $user );
$defaultOptions = $this->defaultOptionsManager->getDefaultOptions();
if ( !is_array( $resetKinds ) ) {
$resetKinds = [ $resetKinds ];
}
if ( in_array( 'all', $resetKinds ) ) {
$newOptions = $defaultOptions;
} else {
$optionKinds = $this->getOptionKinds( $user, $context );
$resetKinds = array_intersect( $resetKinds, $this->listOptionKinds() );
$newOptions = [];
// Use default values for the options that should be deleted, and
// copy old values for the ones that shouldn't.
foreach ( $oldOptions as $key => $value ) {
if ( in_array( $optionKinds[$key], $resetKinds ) ) {
if ( array_key_exists( $key, $defaultOptions ) ) {
$newOptions[$key] = $defaultOptions[$key];
}
} else {
$newOptions[$key] = $value;
}
}
}
// TODO: Deprecate passing full user to the hook
Hooks::run( 'UserResetAllOptions', [
User::newFromIdentity( $user ), &$newOptions, $oldOptions, $resetKinds
] );
$this->optionsCache[$this->getCacheKey( $user )] = $newOptions;
}
/**
* Return a list of the types of user options currently returned by
* UserOptionsManager::getOptionKinds().
*
* Currently, the option kinds are:
* - 'registered' - preferences which are registered in core MediaWiki or
* by extensions using the UserGetDefaultOptions hook.
* - 'registered-multiselect' - as above, using the 'multiselect' type.
* - 'registered-checkmatrix' - as above, using the 'checkmatrix' type.
* - 'userjs' - preferences with names starting with 'userjs-', intended to
* be used by user scripts.
* - 'special' - "preferences" that are not accessible via User::getOptions
* or UserOptionsManager::setOptions.
* - 'unused' - preferences about which MediaWiki doesn't know anything.
* These are usually legacy options, removed in newer versions.
*
* The API (and possibly others) use this function to determine the possible
* option types for validation purposes, so make sure to update this when a
* new option kind is added.
*
* @see getOptionKinds
* @return array Option kinds
*/
public function listOptionKinds(): array {
return [
'registered',
'registered-multiselect',
'registered-checkmatrix',
'userjs',
'special',
'unused'
];
}
/**
* Return an associative array mapping preferences keys to the kind of a preference they're
* used for. Different kinds are handled differently when setting or reading preferences.
*
* See UserOptionsManager::listOptionKinds for the list of valid option types that can be provided.
*
* @see UserOptionsManager::listOptionKinds
* @param UserIdentity $user
* @param IContextSource $context
* @param array|null $options Assoc. array with options keys to check as keys.
* Defaults user options.
* @return array The key => kind mapping data
*/
public function getOptionKinds(
UserIdentity $user,
IContextSource $context,
$options = null
): array {
if ( $options === null ) {
$options = $this->loadUserOptions( $user );
}
// TODO: injecting the preferences factory creates a cyclic dependency between
// PreferencesFactory and UserOptionsManager. See T250822
$preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
$prefs = $preferencesFactory->getFormDescriptor( User::newFromIdentity( $user ), $context );
$mapping = [];
// Pull out the "special" options, so they don't get converted as
// multiselect or checkmatrix.
$specialOptions = array_fill_keys( $preferencesFactory->getSaveBlacklist(), true );
foreach ( $specialOptions as $name => $value ) {
unset( $prefs[$name] );
}
// Multiselect and checkmatrix options are stored in the database with
// one key per option, each having a boolean value. Extract those keys.
$multiselectOptions = [];
foreach ( $prefs as $name => $info ) {
if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
( isset( $info['class'] ) && $info['class'] == HTMLMultiSelectField::class )
) {
$opts = HTMLFormField::flattenOptions( $info['options'] );
$prefix = $info['prefix'] ?? $name;
foreach ( $opts as $value ) {
$multiselectOptions["$prefix$value"] = true;
}
unset( $prefs[$name] );
}
}
$checkmatrixOptions = [];
foreach ( $prefs as $name => $info ) {
if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class )
) {
$columns = HTMLFormField::flattenOptions( $info['columns'] );
$rows = HTMLFormField::flattenOptions( $info['rows'] );
$prefix = $info['prefix'] ?? $name;
foreach ( $columns as $column ) {
foreach ( $rows as $row ) {
$checkmatrixOptions["$prefix$column-$row"] = true;
}
}
unset( $prefs[$name] );
}
}
// $value is ignored
foreach ( $options as $key => $value ) {
if ( isset( $prefs[$key] ) ) {
$mapping[$key] = 'registered';
} elseif ( isset( $multiselectOptions[$key] ) ) {
$mapping[$key] = 'registered-multiselect';
} elseif ( isset( $checkmatrixOptions[$key] ) ) {
$mapping[$key] = 'registered-checkmatrix';
} elseif ( isset( $specialOptions[$key] ) ) {
$mapping[$key] = 'special';
} elseif ( substr( $key, 0, 7 ) === 'userjs-' ) {
$mapping[$key] = 'userjs';
} else {
$mapping[$key] = 'unused';
}
}
return $mapping;
}
/**
* Saves the non-default options for this user, as previously set e.g. via
* setOption(), in the database's "user_properties" (preferences) table.
* Usually used via saveSettings().
* @param UserIdentity $user
* @internal
*/
public function saveOptions( UserIdentity $user ) {
// Not using getOptions(), to keep hidden preferences in database
$saveOptions = $this->loadUserOptions( $user, self::READ_LATEST );
// Allow hooks to abort, for instance to save to a global profile.
// Reset options to default state before saving.
// TODO: Deprecate passing User to the hook.
if ( !Hooks::run( 'UserSaveOptions', [ User::newFromIdentity( $user ), &$saveOptions ] ) ) {
return;
}
$userId = $user->getId();
$insert_rows = []; // all the new preference rows
foreach ( $saveOptions as $key => $value ) {
// Don't bother storing default values
$defaultOption = $this->defaultOptionsManager->getDefaultOption( $key );
if ( ( $defaultOption === null && $value !== false && $value !== null )
|| $value != $defaultOption
) {
$insert_rows[] = [
'up_user' => $userId,
'up_property' => $key,
'up_value' => $value,
];
}
}
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$res = $dbw->select(
'user_properties',
[ 'up_property', 'up_value' ],
[ 'up_user' => $userId ],
__METHOD__
);
// Find prior rows that need to be removed or updated. These rows will
// all be deleted (the latter so that INSERT IGNORE applies the new values).
$keysDelete = [];
foreach ( $res as $row ) {
if ( !isset( $saveOptions[$row->up_property] ) ||
$saveOptions[$row->up_property] !== $row->up_value
) {
$keysDelete[] = $row->up_property;
}
}
if ( !count( $keysDelete ) && !count( $insert_rows ) ) {
return;
}
$this->originalOptionsCache[$this->getCacheKey( $user )] = null;
if ( count( $keysDelete ) ) {
// Do the DELETE by PRIMARY KEY for prior rows.
// In the past a very large portion of calls to this function are for setting
// 'rememberpassword' for new accounts (a preference that has since been removed).
// Doing a blanket per-user DELETE for new accounts with no rows in the table
// caused gap locks on [max user ID,+infinity) which caused high contention since
// updates would pile up on each other as they are for higher (newer) user IDs.
// It might not be necessary these days, but it shouldn't hurt either.
$dbw->delete(
'user_properties',
[
'up_user' => $userId,
'up_property' => $keysDelete
],
__METHOD__
);
}
// Insert the new preference rows
$dbw->insert(
'user_properties',
$insert_rows,
__METHOD__,
[ 'IGNORE' ]
);
}
/**
* @param UserIdentity $user
* @param int $queryFlags
* @param array|null $data preloaded row from the user_properties table
* @return array
* @internal To be called by User loading code to provide the $data
*/
public function loadUserOptions(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL,
array $data = null
): array {
$userKey = $this->getCacheKey( $user );
if ( isset( $this->optionsCache[$userKey] ) ) {
return $this->optionsCache[$userKey];
}
$options = $this->defaultOptionsManager->getDefaultOptions();
if ( !$user->isRegistered() ) {
// For unlogged-in users, load language/variant options from request.
// There's no need to do it for logged-in users: they can set preferences,
// and handling of page content is done by $pageLang->getPreferredVariant() and such,
// so don't override user's choice (especially when the user chooses site default).
$variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant();
$options['variant'] = $variant;
$options['language'] = $variant;
$this->optionsCache[$userKey] = $options;
return $options;
}
// In case options were already loaded from the database before and no options
// changes were saved to the database, we can use the cached original options.
if ( isset( $this->originalOptionsCache[$userKey] ) ) {
$this->logger->debug( 'Loading options from override cache', [
'user_id' => $user->getId()
] );
foreach ( $this->originalOptionsCache[$userKey] as $key => $value ) {
$options[$key] = $value;
}
} else {
if ( !is_array( $data ) ) {
$this->logger->debug( 'Loading options from database', [
'user_id' => $user->getId()
] );
$dbr = $this->getDBForQueryFlags( $queryFlags );
$res = $dbr->select(
'user_properties',
[ 'up_property', 'up_value' ],
[ 'up_user' => $user->getId() ],
__METHOD__
);
$this->originalOptionsCache[$userKey] = [];
$data = [];
foreach ( $res as $row ) {
// Convert '0' to 0. PHP's boolean conversion considers them both
// false, but e.g. JavaScript considers the former as true.
// @todo: T54542 Somehow determine the desired type (string/int/bool)
// and convert all values here.
if ( $row->up_value === '0' ) {
$row->up_value = 0;
}
$data[$row->up_property] = $row->up_value;
}
}
foreach ( $data as $property => $value ) {
$this->originalOptionsCache[$userKey][$property] = $value;
$options[$property] = $value;
}
}
// Replace deprecated language codes
$options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] );
$this->optionsCache[$userKey] = $options;
// TODO: Deprecate passing full User object into the hook.
Hooks::run(
'UserLoadOptions',
[ User::newFromIdentity( $user ), &$this->optionsCache[$userKey] ]
);
return $this->optionsCache[$userKey];
}
/**
* Clears cached user options.
* @internal To be used by User::clearInstanceCache
* @param UserIdentity $user
*/
public function clearUserOptionsCache( UserIdentity $user ) {
$cacheKey = $this->getCacheKey( $user );
$this->optionsCache[$cacheKey] = null;
$this->originalOptionsCache[$cacheKey] = null;
}
/**
* Gets a unique key for various caches.
* @param UserIdentity $user
* @return string
*/
private function getCacheKey( UserIdentity $user ): string {
return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
}
/**
* @param int $queryFlags a bit field composed of READ_XXX flags
* @return IDatabase
*/
private function getDBForQueryFlags( $queryFlags ): IDatabase {
list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
return $this->loadBalancer->getConnectionRef( $mode, [] );
}
}

View file

@ -170,6 +170,9 @@ $wgAutoloadClasses += [
'MediaWiki\Tests\Revision\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Revision/RevisionStoreDbTestBase.php",
'MediaWiki\Tests\Revision\RevisionStoreRecordTest' => "$testDir/phpunit/includes/Revision/RevisionStoreRecordTest.php",
# test/phpunit/includes/user
'UserOptionsLookupTest' => "$testDir/phpunit/includes/user/UserOptionsLookupTest.php",
# tests/phpunit/languages
'DummyConverter' => "$testDir/phpunit/mocks/languages/DummyConverter.php",
'LanguageClassesTestCase' => "$testDir/phpunit/languages/LanguageClassesTestCase.php",

View file

@ -362,7 +362,6 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
public static function resetNonServiceCaches() {
global $wgRequest, $wgJobClasses;
User::resetGetDefaultOptionsForTestsOnly();
foreach ( $wgJobClasses as $type => $class ) {
JobQueueGroup::singleton()->get( $type )->delete();
}

View file

@ -318,9 +318,10 @@ class ApiMainTest extends ApiTestCase {
private function doTestCheckMaxLag( $lag ) {
$mockLB = $this->getMockBuilder( LoadBalancer::class )
->disableOriginalConstructor()
->setMethods( [ 'getMaxLag', '__destruct' ] )
->setMethods( [ 'getMaxLag', 'getConnectionRef', '__destruct' ] )
->getMock();
$mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
$mockLB->method( 'getConnectionRef' )->willReturn( $this->db );
$this->setService( 'DBLoadBalancer', $mockLB );
$req = new FauxRequest();

View file

@ -105,12 +105,6 @@ abstract class AbstractChangesListSpecialPageTestCase extends MediaWikiTestCase
$redirected = true;
}
);
$ctx = new RequestContext();
// Give users patrol permissions so we can test that.
$user = $this->getTestSysop()->getUser();
$user->setOption( 'rcenhancedfilters-disable', $rcfilters ? 0 : 1 );
$ctx->setUser( $user );
// Disable this hook or it could break changeType
// depending on which other extensions are running.
@ -119,6 +113,12 @@ abstract class AbstractChangesListSpecialPageTestCase extends MediaWikiTestCase
null
);
// Give users patrol permissions so we can test that.
$user = $this->getTestSysop()->getUser();
$user->setOption( 'rcenhancedfilters-disable', $rcfilters ? 0 : 1 );
$ctx = new RequestContext();
$ctx->setUser( $user );
$ctx->setOutput( $output );
$clsp = $this->changesListSpecialPage;
$clsp->setContext( $ctx );

View file

@ -0,0 +1,52 @@
<?php
use MediaWiki\User\DefaultOptionsManager;
use MediaWiki\User\UserOptionsLookup;
/**
* @covers MediaWiki\User\DefaultOptionsManager
*/
class DefaultOptionsManagerTest extends UserOptionsLookupTest {
protected function getLookup(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
) : UserOptionsLookup {
return $this->getDefaultManager( $langCode, $defaultOptionsOverrides );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getOption
*/
public function testGetOptionsExcludeDefaults() {
$this->assertSame( [], $this->getLookup()
->getOptions( $this->getAnon(), DefaultOptionsManager::EXCLUDE_DEFAULTS ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getDefaultOptions
*/
public function testGetDefaultOptionsHook() {
$this->setTemporaryHook( 'UserGetDefaultOptions', function ( &$options ) {
$options['from_hook'] = 'value_from_hook';
} );
$this->assertSame( 'value_from_hook', $this->getLookup()->getDefaultOption( 'from_hook' ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getDefaultOptions
*/
public function testSearchNS() {
$this->assertTrue( $this->getLookup()->getDefaultOption( 'searchNs0' ) );
$this->assertNull( $this->getLookup()->getDefaultOption( 'searchNs5' ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getDefaultOptions
*/
public function testLangVariantOptions() {
$managerZh = $this->getLookup( 'zh' );
$this->assertSame( 'zh', $managerZh->getDefaultOption( 'language' ) );
$this->assertSame( 'gan', $managerZh->getDefaultOption( 'variant-gan' ) );
$this->assertSame( 'zh', $managerZh->getDefaultOption( 'variant' ) );
}
}

View file

@ -0,0 +1,138 @@
<?php
use MediaWiki\Config\ServiceOptions;
use MediaWiki\User\DefaultOptionsManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserOptionsLookup;
/**
* @covers MediaWiki\User\DefaultOptionsManager
* @covers MediaWiki\User\UserOptionsManager
* @covers MediaWiki\User\UserOptionsLookup
*/
abstract class UserOptionsLookupTest extends MediaWikiIntegrationTestCase {
use MediaWikiCoversValidator;
protected function getAnon(
string $name = 'anon'
) : UserIdentity {
return new UserIdentityValue( 0, $name, 0 );
}
abstract protected function getLookup(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
) : UserOptionsLookup;
protected function getDefaultManager(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
) : DefaultOptionsManager {
$lang = $this->createMock( Language::class );
$lang->method( 'getCode' )->willReturn( $langCode );
return new DefaultOptionsManager(
new ServiceOptions(
DefaultOptionsManager::CONSTRUCTOR_OPTIONS,
new HashConfig( [
'DefaultSkin' => 'test',
'DefaultUserOptions' => array_merge( [
'default_string_option' => 'string_value',
'default_int_option' => 1,
'default_bool_option' => true
], $defaultOptionsOverrides ),
'NamespacesToBeSearchedDefault' => [
NS_MAIN => true,
NS_TALK => true
]
] )
),
$lang
);
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getDefaultOptions
* @covers MediaWiki\User\UserOptionsManager::getDefaultOptions
*/
public function testGetDefaultOptions() {
$options = $this->getLookup()->getDefaultOptions();
$this->assertSame( 'string_value', $options['default_string_option'] );
$this->assertSame( 1, $options['default_int_option'] );
$this->assertSame( true, $options['default_bool_option'] );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getDefaultOption
* @covers MediaWiki\User\UserOptionsManager::getDefaultOption
*/
public function testGetDefaultOption() {
$manager = $this->getLookup();
$this->assertSame( 'string_value', $manager->getDefaultOption( 'default_string_option' ) );
$this->assertSame( 1, $manager->getDefaultOption( 'default_int_option' ) );
$this->assertSame( true, $manager->getDefaultOption( 'default_bool_option' ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getOptions
* @covers MediaWiki\User\UserOptionsManager::getOptions
*/
public function testGetOptions() {
$options = $this->getLookup()->getOptions( $this->getAnon() );
$this->assertSame( 'string_value', $options['default_string_option'] );
$this->assertSame( 1, $options['default_int_option'] );
$this->assertSame( true, $options['default_bool_option'] );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getOption
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionDefault() {
$manager = $this->getLookup();
$this->assertSame( 'string_value',
$manager->getOption( $this->getAnon(), 'default_string_option' ) );
$this->assertSame( 1, $manager->getOption( $this->getAnon(), 'default_int_option' ) );
$this->assertSame( true, $manager->getOption( $this->getAnon(), 'default_bool_option' ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getOption
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionDefaultNotExist() {
$this->assertNull( $this->getLookup()
->getOption( $this->getAnon(), 'this_option_does_not_exist' ) );
}
/**
* @covers MediaWiki\User\DefaultOptionsManager::getOption
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionDefaultNotExistDefaultOverride() {
$this->assertSame( 'override', $this->getLookup()
->getOption( $this->getAnon(), 'this_option_does_not_exist', 'override' ) );
}
/**
* @covers MediaWiki\User\UserOptionsLookup::getIntOption
*/
public function testGetIntOption() {
$this->assertSame(
2,
$this->getLookup( 'qqq', [ 'default_int_option' => '2' ] )
->getIntOption( $this->getAnon(), 'default_int_option' )
);
}
/**
* @covers MediaWiki\User\UserOptionsLookup::getBoolOption
*/
public function testGetBoolOption() {
$this->assertSame(
true,
$this->getLookup( 'qqq', [ 'default_bool_option' => 'true' ] )
->getBoolOption( $this->getAnon(), 'default_bool_option' )
);
}
}

View file

@ -0,0 +1,149 @@
<?php
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
use Psr\Log\NullLogger;
/**
* @group Database
* @covers MediaWiki\User\UserOptionsManager
*/
class UserOptionsManagerTest extends UserOptionsLookupTest {
private function getManager(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
) {
$services = MediaWikiServices::getInstance();
return new UserOptionsManager(
new ServiceOptions(
UserOptionsManager::CONSTRUCTOR_OPTIONS,
new HashConfig( [ 'HiddenPrefs' => [ 'hidden_user_option' ] ] )
),
$this->getDefaultManager( $langCode, $defaultOptionsOverrides ),
$services->getLanguageConverterFactory(),
$services->getDBLoadBalancer(),
new NullLogger()
);
}
protected function getLookup(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
) : UserOptionsLookup {
return $this->getManager( $langCode, $defaultOptionsOverrides );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionsExcludeDefaults() {
$manager = $this->getManager();
$manager->setOption( $this->getAnon( __METHOD__ ), 'new_option', 'new_value' );
$this->assertSame( [
'language' => 'en',
'variant' => 'en',
'new_option' => 'new_value'
], $manager->getOptions( $this->getAnon( __METHOD__ ), UserOptionsManager::EXCLUDE_DEFAULTS ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionHiddenPref() {
$user = $this->getAnon( __METHOD__ );
$manager = $this->getManager();
$manager->setOption( $user, 'hidden_user_option', 'hidden_value' );
$this->assertNull( $manager->getOption( $user, 'hidden_user_option' ) );
$this->assertSame( 'hidden_value',
$manager->getOption( $user, 'hidden_user_option', null, true ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::setOption
*/
public function testSetOptionNullIsDefault() {
$user = $this->getAnon( __METHOD__ );
$manager = $this->getManager();
$manager->setOption( $user, 'default_string_option', 'override_value' );
$this->assertSame( 'override_value', $manager->getOption( $user, 'default_string_option' ) );
$manager->setOption( $user, 'default_string_option', null );
$this->assertSame( 'string_value', $manager->getOption( $user, 'default_string_option' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
* @covers MediaWiki\User\UserOptionsManager::setOption
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testGetSetSave() {
$user = $this->getTestUser()->getUser();
$manager = $this->getManager();
$this->assertSame( [], $manager->getOptions( $user, UserOptionsManager::EXCLUDE_DEFAULTS ) );
$manager->setOption( $user, 'string_option', 'user_value' );
$manager->setOption( $user, 'int_option', 42 );
$manager->setOption( $user, 'bool_option', true );
$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
$manager->saveOptions( $user );
$manager = $this->getManager();
$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::loadUserOptions
*/
public function testLoadUserOptionsHook() {
$user = $this->getTestUser()->getUser();
$this->setTemporaryHook(
'UserLoadOptions',
function ( User $hookUser, &$options ) use ( $user ) {
if ( $hookUser->equals( $user ) ) {
$options['from_hook'] = 'value_from_hook';
}
}
);
$this->assertSame( 'value_from_hook', $this->getManager()->getOption( $user, 'from_hook' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testSaveUserOptionsHookAbort() {
$user = $this->getTestUser()->getUser();
$this->setTemporaryHook(
'UserSaveOptions',
function () {
return false;
}
);
$manager = $this->getManager();
$manager->setOption( $user, 'will_be_aborted_by_hook', 'value' );
$manager->saveOptions( $user );
$this->assertNull( $this->getManager()->getOption( $user, 'will_be_aborted_by_hook' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testSaveUserOptionsHookModify() {
$user = $this->getTestUser()->getUser();
$this->setTemporaryHook(
'UserLoadOptions',
function ( User $hookUser, &$options ) use ( $user ) {
if ( $hookUser->equals( $user ) ) {
$options['from_hook'] = 'value_from_hook';
}
}
);
$manager = $this->getManager();
$manager->saveOptions( $user );
$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'from_hook' ) );
$this->assertSame( 'value_from_hook', $this->getManager()->getOption( $user, 'from_hook' ) );
}
}

View file

@ -458,8 +458,7 @@ class UserTest extends MediaWikiTestCase {
$user->setOption( 'userjs-usedefaultoverride', '' );
$user->saveSettings();
$user = User::newFromName( $user->getName() );
$user->load( User::READ_LATEST );
MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
$this->assertSame( 'test', $user->getOption( 'userjs-someoption' ) );
$this->assertTrue( $user->getBoolOption( 'userjs-someoption' ) );
$this->assertEquals( 200, $user->getOption( 'rclimit' ) );
@ -475,7 +474,7 @@ class UserTest extends MediaWikiTestCase {
'Valid stub threshold preferences are respected'
);
$user = User::newFromName( $user->getName() );
MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache();
$this->assertSame( 'test', $user->getOption( 'userjs-someoption' ) );
$this->assertTrue( $user->getBoolOption( 'userjs-someoption' ) );
@ -493,8 +492,7 @@ class UserTest extends MediaWikiTestCase {
);
// Check that an option saved as a string '0' is returned as an integer.
$user = User::newFromName( $user->getName() );
$user->load( User::READ_LATEST );
MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
$this->assertSame( 0, $user->getOption( 'wpwatchlistdays' ) );
$this->assertFalse( $user->getBoolOption( 'wpwatchlistdays' ) );
@ -511,7 +509,7 @@ class UserTest extends MediaWikiTestCase {
/**
* T39963
* Make sure defaults are loaded when setOption is called.
* @covers User::loadOptions
* @covers User::setOption
*/
public function testAnonOptions() {
global $wgDefaultUserOptions;
@ -2172,8 +2170,8 @@ class UserTest extends MediaWikiTestCase {
* @covers User::getDefaultOption
* @covers User::getDefaultOptions
*/
public function testDefaultOptions() {
User::resetGetDefaultOptionsForTestsOnly();
public function testGetDefaultOptions() {
$this->resetServices();
$this->setTemporaryHook( 'UserGetDefaultOptions', function ( &$defaults ) {
$defaults['extraoption'] = 42;

View file

@ -150,7 +150,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant', 'tg-latn' );
$wgUser = $user;
@ -168,7 +167,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant', 'bat-smg' );
$wgUser = $user;
@ -186,7 +184,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant', 'en-simple' );
$wgUser = $user;
@ -206,7 +203,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant-tg', 'tg-latn' );
$wgUser = $user;
@ -226,7 +222,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant-tg', 'bat-smg' );
$wgUser = $user;
@ -246,7 +241,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user->load(); // from 'defaults'
$user->mId = 1;
$user->mDataLoaded = true;
$user->mOptionsLoaded = true;
$user->setOption( 'variant-tg', 'en-simple' );
$wgUser = $user;
@ -267,7 +261,6 @@ class LanguageConverterTest extends MediaWikiLangTestCase {
$user = User::newFromId( "admin" );
$user->setId( 1 );
$user->mFrom = 'defaults';
$user->mOptionsLoaded = true;
// The user's data is ignored because the variant is set in the URL.
$user->setOption( 'variant', 'tg-latn' );