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