Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.62% covered (warning)
83.62%
148 / 177
50.00% covered (danger)
50.00%
11 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserOptionsManager
84.09% covered (warning)
84.09%
148 / 176
50.00% covered (danger)
50.00%
11 / 22
94.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOption
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getOptions
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 isOptionGlobal
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setOption
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 resetOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 resetOptionsByName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resetAllOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 listOptionKinds
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getOptionKinds
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 saveOptions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 saveOptionsInternal
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
13.15
 loadUserOptions
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 clearUserOptionsCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadOptionsFromStore
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
8.83
 normalizeValueType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 loadOriginalOptions
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
8
 getCacheKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 isValueEqual
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getStores
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\User\Options;
22
23use InvalidArgumentException;
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\Context\IContextSource;
26use MediaWiki\HookContainer\HookContainer;
27use MediaWiki\HookContainer\HookRunner;
28use MediaWiki\Language\LanguageCode;
29use MediaWiki\Language\LanguageConverter;
30use MediaWiki\Languages\LanguageConverterFactory;
31use MediaWiki\MainConfigNames;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\User\UserFactory;
34use MediaWiki\User\UserIdentity;
35use MediaWiki\User\UserNameUtils;
36use MediaWiki\User\UserTimeCorrection;
37use Psr\Log\LoggerInterface;
38use Wikimedia\ObjectFactory\ObjectFactory;
39use Wikimedia\Rdbms\IConnectionProvider;
40use Wikimedia\Rdbms\IDBAccessObject;
41
42/**
43 * A service class to control user options
44 * @since 1.35
45 * @ingroup User
46 */
47class UserOptionsManager extends UserOptionsLookup {
48
49    /**
50     * @internal For use by ServiceWiring
51     */
52    public const CONSTRUCTOR_OPTIONS = [
53        MainConfigNames::HiddenPrefs,
54        MainConfigNames::LocalTZoffset,
55    ];
56
57    /**
58     * @since 1.39.5, 1.40
59     */
60    public const MAX_BYTES_OPTION_VALUE = 65530;
61
62    /**
63     * If the option was set globally, ignore the update.
64     * @since 1.43
65     */
66    public const GLOBAL_IGNORE = 'ignore';
67
68    /**
69     * If the option was set globally, add a local override.
70     * @since 1.43
71     */
72    public const GLOBAL_OVERRIDE = 'override';
73
74    /**
75     * If the option was set globally, update the global value.
76     * @since 1.43
77     */
78    public const GLOBAL_UPDATE = 'update';
79
80    private const LOCAL_STORE_KEY = 'local';
81
82    private ServiceOptions $serviceOptions;
83    private DefaultOptionsLookup $defaultOptionsLookup;
84    private LanguageConverterFactory $languageConverterFactory;
85    private IConnectionProvider $dbProvider;
86    private UserFactory $userFactory;
87    private LoggerInterface $logger;
88    private HookRunner $hookRunner;
89    private UserNameUtils $userNameUtils;
90    private array $storeProviders;
91
92    private ObjectFactory $objectFactory;
93
94    /** @var UserOptionsCacheEntry[] */
95    private $cache = [];
96
97    /** @var UserOptionsStore[]|null */
98    private $stores;
99
100    /**
101     * @param ServiceOptions $options
102     * @param DefaultOptionsLookup $defaultOptionsLookup
103     * @param LanguageConverterFactory $languageConverterFactory
104     * @param IConnectionProvider $dbProvider
105     * @param LoggerInterface $logger
106     * @param HookContainer $hookContainer
107     * @param UserFactory $userFactory
108     * @param UserNameUtils $userNameUtils
109     * @param ObjectFactory $objectFactory
110     * @param array $storeProviders
111     */
112    public function __construct(
113        ServiceOptions $options,
114        DefaultOptionsLookup $defaultOptionsLookup,
115        LanguageConverterFactory $languageConverterFactory,
116        IConnectionProvider $dbProvider,
117        LoggerInterface $logger,
118        HookContainer $hookContainer,
119        UserFactory $userFactory,
120        UserNameUtils $userNameUtils,
121        ObjectFactory $objectFactory,
122        array $storeProviders
123    ) {
124        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
125        $this->serviceOptions = $options;
126        $this->defaultOptionsLookup = $defaultOptionsLookup;
127        $this->languageConverterFactory = $languageConverterFactory;
128        $this->dbProvider = $dbProvider;
129        $this->logger = $logger;
130        $this->hookRunner = new HookRunner( $hookContainer );
131        $this->userFactory = $userFactory;
132        $this->userNameUtils = $userNameUtils;
133        $this->objectFactory = $objectFactory;
134        $this->storeProviders = $storeProviders;
135    }
136
137    /**
138     * @inheritDoc
139     */
140    public function getDefaultOptions( ?UserIdentity $userIdentity = null ): array {
141        return $this->defaultOptionsLookup->getDefaultOptions( $userIdentity );
142    }
143
144    /**
145     * @inheritDoc
146     */
147    public function getDefaultOption( string $opt, ?UserIdentity $userIdentity = null ) {
148        return $this->defaultOptionsLookup->getDefaultOption( $opt, $userIdentity );
149    }
150
151    /**
152     * @inheritDoc
153     */
154    public function getOption(
155        UserIdentity $user,
156        string $oname,
157        $defaultOverride = null,
158        bool $ignoreHidden = false,
159        int $queryFlags = IDBAccessObject::READ_NORMAL
160    ) {
161        # We want 'disabled' preferences to always behave as the default value for
162        # users, even if they have set the option explicitly in their settings (ie they
163        # set it, and then it was disabled removing their ability to change it).  But
164        # we don't want to erase the preferences in the database in case the preference
165        # is re-enabled again.  So don't touch $mOptions, just override the returned value
166        if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) ) ) {
167            return $this->defaultOptionsLookup->getDefaultOption( $oname, $user );
168        }
169
170        $options = $this->loadUserOptions( $user, $queryFlags );
171        if ( array_key_exists( $oname, $options ) ) {
172            return $options[$oname];
173        }
174        return $defaultOverride;
175    }
176
177    /**
178     * @inheritDoc
179     */
180    public function getOptions(
181        UserIdentity $user,
182        int $flags = 0,
183        int $queryFlags = IDBAccessObject::READ_NORMAL
184    ): array {
185        $options = $this->loadUserOptions( $user, $queryFlags );
186
187        # We want 'disabled' preferences to always behave as the default value for
188        # users, even if they have set the option explicitly in their settings (ie they
189        # set it, and then it was disabled removing their ability to change it).  But
190        # we don't want to erase the preferences in the database in case the preference
191        # is re-enabled again.  So don't touch $mOptions, just override the returned value
192        foreach ( $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) as $pref ) {
193            $default = $this->defaultOptionsLookup->getDefaultOption( $pref, $user );
194            if ( $default !== null ) {
195                $options[$pref] = $default;
196            }
197        }
198
199        if ( $flags & self::EXCLUDE_DEFAULTS ) {
200            // NOTE: This intentionally ignores conditional defaults, so that `mw.user.options`
201            // work correctly for options with conditional defaults.
202            $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( null );
203            foreach ( $options as $option => $value ) {
204                if ( array_key_exists( $option, $defaultOptions )
205                    && self::isValueEqual( $value, $defaultOptions[$option] )
206                ) {
207                    unset( $options[$option] );
208                }
209            }
210        }
211
212        return $options;
213    }
214
215    public function isOptionGlobal( UserIdentity $user, string $key ) {
216        $this->getOptions( $user );
217        $source = $this->cache[ $this->getCacheKey( $user ) ]->sources[$key] ?? self::LOCAL_STORE_KEY;
218        return $source !== self::LOCAL_STORE_KEY;
219    }
220
221    /**
222     * Set the given option for a user.
223     *
224     * You need to call saveOptions() to actually write to the database.
225     *
226     * $val should be null or a string. Other types are accepted for B/C with legacy
227     * code but can result in surprising behavior and are discouraged. Values are always
228     * stored as strings in the database, so if you pass a non-string value, it will be
229     * eventually converted; but before the call to saveOptions(), getOption() will return
230     * the passed value from instance cache without any type conversion.
231     *
232     * A null value means resetting the option to its default value (removing the user_properties
233     * row). Passing in the same value as the default value fo the user has the same result.
234     * This behavior supports some level of type juggling - e.g. if the default value is 1,
235     * and you pass in '1', the option will be reset to its default value.
236     *
237     * When an option is reset to its default value, that means whenever the default value
238     * is changed in the site configuration, the user preference for this user will also change.
239     * There is no way to set a user preference to be the same as the default but avoid it
240     * changing when the default changes. You can instead use $wgConditionalUserOptions to
241     * split the default based on user registration date.
242     *
243     * If a global user option exists with the given name, the behaviour depends on the value
244     * of $global.
245     *
246     * @param UserIdentity $user
247     * @param string $oname The option to set
248     * @param mixed $val New value to set.
249     * @param string $global Since 1.43. What to do if the option was set
250     *   globally using the GlobalPreferences extension. One of the
251     *   self::GLOBAL_* constants:
252     *   - GLOBAL_IGNORE: Do nothing. The option remains with its previous value.
253     *   - GLOBAL_OVERRIDE: Add a local override.
254     *   - GLOBAL_UPDATE: Update the option globally.
255     *   The UI should typically ask for the user's consent before setting a global
256     *   option.
257     */
258    public function setOption( UserIdentity $user, string $oname, $val,
259        $global = self::GLOBAL_IGNORE
260    ) {
261        // Explicitly NULL values should refer to defaults
262        $val ??= $this->defaultOptionsLookup->getDefaultOption( $oname, $user );
263        $userKey = $this->getCacheKey( $user );
264        $info = $this->cache[$userKey] ??= new UserOptionsCacheEntry;
265        $info->modifiedValues[$oname] = $val;
266        $info->globalUpdateActions[$oname] = $global;
267    }
268
269    /**
270     * Reset certain (or all) options to the site defaults
271     *
272     * The optional parameter determines which kinds of preferences will be reset.
273     * Supported values are everything that can be reported by getOptionKinds()
274     * and 'all', which forces a reset of *all* preferences and overrides everything else.
275     *
276     * @note You need to call saveOptions() to actually write to the database.
277     *
278     * @deprecated since 1.43 use resetOptionsByName() with PreferencesFactory::getOptionNamesForReset()
279     *
280     * @param UserIdentity $user
281     * @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
282     * @param array|string $resetKinds Which kinds of preferences to reset.
283     *  Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
284     */
285    public function resetOptions(
286        UserIdentity $user,
287        IContextSource $context,
288        $resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
289    ) {
290        wfDeprecated( __METHOD__, '1.43' );
291        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
292        $optionsToReset = $preferencesFactory->getOptionNamesForReset(
293            $this->userFactory->newFromUserIdentity( $user ), $context, $resetKinds );
294        $this->resetOptionsByName( $user, $optionsToReset );
295    }
296
297    /**
298     * Reset a list of options to the site defaults
299     *
300     * @note You need to call saveOptions() to actually write to the database.
301     *
302     * @param UserIdentity $user
303     * @param string[] $optionNames
304     */
305    public function resetOptionsByName(
306        UserIdentity $user,
307        array $optionNames
308    ) {
309        foreach ( $optionNames as $name ) {
310            $this->setOption( $user, $name, null );
311        }
312    }
313
314    /**
315     * Reset all options that were set to a non-default value by the given user
316     *
317     * @note You need to call saveOptions() to actually write to the database.
318     *
319     * @param UserIdentity $user
320     */
321    public function resetAllOptions( UserIdentity $user ) {
322        foreach ( $this->loadUserOptions( $user ) as $name => $value ) {
323            $this->setOption( $user, $name, null );
324        }
325    }
326
327    /**
328     * @deprecated since 1.43 use PreferencesFactory::listResetKinds()
329     *
330     * @return string[] Option kinds
331     */
332    public function listOptionKinds(): array {
333        wfDeprecated( __METHOD__, '1.43' );
334        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
335        return $preferencesFactory->listResetKinds();
336    }
337
338    /**
339     * @deprecated since 1.43 use PreferencesFactory::getResetKinds
340     *
341     * @param UserIdentity $userIdentity
342     * @param IContextSource $context
343     * @param array|null $options
344     * @return string[]
345     */
346    public function getOptionKinds(
347        UserIdentity $userIdentity,
348        IContextSource $context,
349        $options = null
350    ): array {
351        wfDeprecated( __METHOD__, '1.43' );
352        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
353        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
354        return $preferencesFactory->getResetKinds( $user, $context, $options );
355    }
356
357    /**
358     * Saves the non-default options for this user, as previously set e.g. via
359     * setOption(), in the database's "user_properties" (preferences) table.
360     *
361     * @since 1.38, this method was internal before that.
362     * @param UserIdentity $user
363     */
364    public function saveOptions( UserIdentity $user ) {
365        $dbw = $this->dbProvider->getPrimaryDatabase();
366        $changed = $this->saveOptionsInternal( $user );
367        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
368        // Before UserOptionsManager, User::saveSettings was used for user options
369        // saving. Some extensions might depend on UserSaveSettings hook being run
370        // when options are saved, so run this hook for legacy reasons.
371        // Once UserSaveSettings hook is deprecated and replaced with a different hook
372        // with more modern interface, extensions should use 'SaveUserOptions' hook.
373        $this->hookRunner->onUserSaveSettings( $legacyUser );
374        if ( $changed ) {
375            $dbw->onTransactionCommitOrIdle( static function () use ( $legacyUser ) {
376                $legacyUser->checkAndSetTouched();
377            }, __METHOD__ );
378        }
379    }
380
381    /**
382     * Saves the non-default options for this user, as previously set e.g. via
383     * setOption(), in the database's "user_properties" (preferences) table.
384     *
385     * @param UserIdentity $user
386     * @return bool true if options were changed and new options successfully saved.
387     * @internal only public for use in User::saveSettings
388     */
389    public function saveOptionsInternal( UserIdentity $user ): bool {
390        if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) {
391            throw new InvalidArgumentException( __METHOD__ . ' was called on IP or temporary user' );
392        }
393
394        $userKey = $this->getCacheKey( $user );
395        $cache = $this->cache[$userKey] ?? new UserOptionsCacheEntry;
396        $modifiedOptions = $cache->modifiedValues;
397
398        // FIXME: should probably use READ_LATEST here
399        $originalOptions = $this->loadOriginalOptions( $user );
400
401        if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) {
402            return false;
403        }
404
405        $updatesByStore = [];
406        foreach ( $modifiedOptions as $key => $value ) {
407            // Don't store unchanged or default values
408            $defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key, $user );
409            if ( $value === null || self::isValueEqual( $value, $defaultValue ) ) {
410                $valOrNull = null;
411            } else {
412                $valOrNull = (string)$value;
413            }
414            $source = $cache->sources[$key] ?? self::LOCAL_STORE_KEY;
415            if ( $source === self::LOCAL_STORE_KEY ) {
416                $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull;
417            } else {
418                $updateAction = $cache->globalUpdateActions[$key] ?? self::GLOBAL_IGNORE;
419                if ( $updateAction === self::GLOBAL_UPDATE ) {
420                    $updatesByStore[$source][$key] = $valOrNull;
421                } elseif ( $updateAction === self::GLOBAL_OVERRIDE ) {
422                    $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull;
423                    $updatesByStore[self::LOCAL_STORE_KEY][$key . self::LOCAL_EXCEPTION_SUFFIX] = '1';
424                }
425            }
426        }
427        $changed = false;
428        $stores = $this->getStores();
429        foreach ( $updatesByStore as $source => $updates ) {
430            $changed = $stores[$source]->store( $user, $updates ) || $changed;
431        }
432
433        if ( !$changed ) {
434            return false;
435        }
436
437        // Clear the cache and the update queue
438        unset( $this->cache[$userKey] );
439        return true;
440    }
441
442    /**
443     * Loads user options either from cache or from the database.
444     *
445     * @note Query flags are ignored for anons, since they do not have any
446     * options stored in the database. If the UserIdentity was itself
447     * obtained from a replica and doesn't have ID set due to replication lag,
448     * it will be treated as anon regardless of the query flags passed here.
449     *
450     * @internal
451     *
452     * @param UserIdentity $user
453     * @param int $queryFlags
454     * @return array
455     */
456    public function loadUserOptions(
457        UserIdentity $user,
458        int $queryFlags = IDBAccessObject::READ_NORMAL
459    ): array {
460        $userKey = $this->getCacheKey( $user );
461        $originalOptions = $this->loadOriginalOptions( $user, $queryFlags );
462        $cache = $this->cache[$userKey] ?? null;
463        if ( $cache ) {
464            return array_merge( $originalOptions, $cache->modifiedValues );
465        } else {
466            return $originalOptions;
467        }
468    }
469
470    /**
471     * Clears cached user options.
472     * @internal To be used by User::clearInstanceCache
473     * @param UserIdentity $user
474     */
475    public function clearUserOptionsCache( UserIdentity $user ) {
476        unset( $this->cache[ $this->getCacheKey( $user ) ] );
477    }
478
479    /**
480     * Fetches the options directly from the database with no caches.
481     *
482     * @param UserIdentity $user
483     * @param int $queryFlags a bit field composed of READ_XXX flags
484     * @return array
485     */
486    private function loadOptionsFromStore(
487        UserIdentity $user,
488        int $queryFlags
489    ): array {
490        $this->logger->debug( 'Loading options from database',
491            [ 'user_id' => $user->getId(), 'user_name' => $user->getName() ] );
492        $mergedOptions = [];
493        $cache = $this->cache[ $this->getCacheKey( $user ) ] ??= new UserOptionsCacheEntry;
494        foreach ( $this->getStores() as $storeName => $store ) {
495            $options = $store->fetch( $user, $queryFlags );
496            foreach ( $options as $name => $value ) {
497                // Handle a local exception which is the default
498                if ( str_ends_with( $name, self::LOCAL_EXCEPTION_SUFFIX ) && $value ) {
499                    $baseName = substr( $name, 0, -strlen( self::LOCAL_EXCEPTION_SUFFIX ) );
500                    if ( !isset( $options[$baseName] ) ) {
501                        // T368595: The source should always be set to local for local exceptions
502                        $cache->sources[$baseName] = self::LOCAL_STORE_KEY;
503                        unset( $mergedOptions[$baseName] );
504                    }
505                }
506
507                // Handle a non-default option or non-default local exception
508                if ( !isset( $mergedOptions[$name] )
509                    || !empty( $options[$name . self::LOCAL_EXCEPTION_SUFFIX] )
510                ) {
511                    $cache->sources[$name] = $storeName;
512                    $mergedOptions[$name] = $this->normalizeValueType( $value );
513                }
514            }
515        }
516        return $mergedOptions;
517    }
518
519    /**
520     * Convert '0' to 0. PHP's boolean conversion considers them both
521     * false, but e.g. JavaScript considers the former as true.
522     *
523     * @todo T54542 Somehow determine the desired type (string/int/bool)
524     *   and convert all values here.
525     *
526     * @param string $value
527     * @return mixed
528     */
529    private function normalizeValueType( $value ) {
530        if ( $value === '0' ) {
531            $value = 0;
532        }
533        return $value;
534    }
535
536    /**
537     * Loads the original user options from the database and applies various transforms,
538     * like timecorrection. Runs hooks.
539     *
540     * @param UserIdentity $user
541     * @param int $queryFlags
542     * @return array
543     */
544    private function loadOriginalOptions(
545        UserIdentity $user,
546        int $queryFlags = IDBAccessObject::READ_NORMAL
547    ): array {
548        $userKey = $this->getCacheKey( $user );
549        $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user );
550        $cache = $this->cache[$userKey] ??= new UserOptionsCacheEntry;
551        if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) {
552            // For unlogged-in users, load language/variant options from request.
553            // There's no need to do it for logged-in users: they can set preferences,
554            // and handling of page content is done by $pageLang->getPreferredVariant() and such,
555            // so don't override user's choice (especially when the user chooses site default).
556            $variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant();
557            $defaultOptions['variant'] = $variant;
558            $defaultOptions['language'] = $variant;
559            $cache->originalValues = $defaultOptions;
560            return $defaultOptions;
561        }
562
563        // In case options were already loaded from the database before and no options
564        // changes were saved to the database, we can use the cached original options.
565        if ( $cache->canUseCachedValues( $queryFlags )
566            && $cache->originalValues !== null
567        ) {
568            return $cache->originalValues;
569        }
570
571        $options = $this->loadOptionsFromStore( $user, $queryFlags ) + $defaultOptions;
572
573        // Replace deprecated language codes
574        $options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] );
575        $options['variant'] = LanguageCode::replaceDeprecatedCodes( $options['variant'] );
576        foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
577            $variant = "variant-$langCode";
578            if ( isset( $options[$variant] ) ) {
579                $options[$variant] = LanguageCode::replaceDeprecatedCodes( $options[$variant] );
580            }
581        }
582
583        // Fix up timezone offset (Due to DST it can change from what was stored in the DB)
584        // ZoneInfo|offset|TimeZoneName
585        if ( isset( $options['timecorrection'] ) ) {
586            $options['timecorrection'] = ( new UserTimeCorrection(
587                $options['timecorrection'],
588                null,
589                $this->serviceOptions->get( MainConfigNames::LocalTZoffset )
590            ) )->toString();
591        }
592
593        // Need to store what we have so far before the hook to prevent
594        // infinite recursion if the hook attempts to reload options
595        $cache->originalValues = $options;
596        $cache->recency = $queryFlags;
597        $this->hookRunner->onLoadUserOptions( $user, $options );
598        $cache->originalValues = $options;
599        return $options;
600    }
601
602    /**
603     * Get a cache key for a user
604     * @param UserIdentity $user
605     * @return string
606     */
607    private function getCacheKey( UserIdentity $user ): string {
608        $name = $user->getName();
609        if ( $this->userNameUtils->isIP( $name ) || $this->userNameUtils->isTemp( $name ) ) {
610            // IP and temporary users may not have custom preferences, so they can share a key
611            return 'anon';
612        } elseif ( $user->isRegistered() ) {
613            return "u:{$user->getId()}";
614        } else {
615            // Allow users with no local account to have preferences provided by alternative
616            // UserOptionsStore implementations (e.g. in GlobalPreferences)
617            $canonical = $this->userNameUtils->getCanonical( $name ) ?: $name;
618            return "a:$canonical";
619        }
620    }
621
622    /**
623     * Determines whether two values are sufficiently similar that the database
624     * does not need to be updated to reflect the change. This is basically the
625     * same as comparing the result of Database::addQuotes().
626     *
627     * @since 1.43
628     *
629     * @param mixed $a
630     * @param mixed $b
631     * @return bool
632     */
633    public static function isValueEqual( $a, $b ) {
634        // null is only equal to another null (T355086)
635        if ( $a === null || $b === null ) {
636            return $a === $b;
637        }
638
639        if ( is_bool( $a ) ) {
640            $a = (int)$a;
641        }
642        if ( is_bool( $b ) ) {
643            $b = (int)$b;
644        }
645        return (string)$a === (string)$b;
646    }
647
648    /**
649     * Get the storage backends in descending order of priority
650     *
651     * @return UserOptionsStore[]
652     */
653    private function getStores() {
654        if ( !$this->stores ) {
655            $stores = [
656                self::LOCAL_STORE_KEY => new LocalUserOptionsStore( $this->dbProvider )
657            ];
658            foreach ( $this->storeProviders as $name => $spec ) {
659                $store = $this->objectFactory->createObject( $spec );
660                if ( !$store instanceof UserOptionsStore ) {
661                    throw new \RuntimeException( "Invalid type for extension store \"$name\"" );
662                }
663                $stores[$name] = $store;
664            }
665            // Query global providers first, preserve keys
666            $this->stores = array_reverse( $stores, true );
667        }
668        return $this->stores;
669    }
670}
671
672/** @deprecated class alias since 1.42 */
673class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' );