Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.19% covered (warning)
77.19%
132 / 171
45.45% covered (danger)
45.45%
10 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserOptionsManager
77.65% covered (warning)
77.65%
132 / 170
45.45% covered (danger)
45.45%
10 / 22
129.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 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
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
14.23
 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
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
8.51
 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
 getCacheKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isValueEqual
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getStores
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
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 ExtensionRegistry;
24use IDBAccessObject;
25use InvalidArgumentException;
26use LanguageCode;
27use LanguageConverter;
28use MediaWiki\Config\ServiceOptions;
29use MediaWiki\Context\IContextSource;
30use MediaWiki\HookContainer\HookContainer;
31use MediaWiki\HookContainer\HookRunner;
32use MediaWiki\Languages\LanguageConverterFactory;
33use MediaWiki\MainConfigNames;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\User\UserFactory;
36use MediaWiki\User\UserIdentity;
37use MediaWiki\User\UserNameUtils;
38use MediaWiki\User\UserTimeCorrection;
39use Psr\Log\LoggerInterface;
40use Wikimedia\ObjectFactory\ObjectFactory;
41use Wikimedia\Rdbms\IConnectionProvider;
42
43/**
44 * A service class to control user options
45 * @since 1.35
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 ServiceOptions $serviceOptions;
81    private DefaultOptionsLookup $defaultOptionsLookup;
82    private LanguageConverterFactory $languageConverterFactory;
83    private IConnectionProvider $dbProvider;
84    private UserFactory $userFactory;
85    private LoggerInterface $logger;
86    private HookRunner $hookRunner;
87    private UserNameUtils $userNameUtils;
88    private ObjectFactory $objectFactory;
89
90    /** @var UserOptionsCacheEntry[] */
91    private $cache = [];
92
93    /** @var UserOptionsStore[]|null */
94    private $stores;
95
96    /**
97     * @param ServiceOptions $options
98     * @param DefaultOptionsLookup $defaultOptionsLookup
99     * @param LanguageConverterFactory $languageConverterFactory
100     * @param IConnectionProvider $dbProvider
101     * @param LoggerInterface $logger
102     * @param HookContainer $hookContainer
103     * @param UserFactory $userFactory
104     * @param UserNameUtils $userNameUtils
105     * @param ObjectFactory $objectFactory
106     */
107    public function __construct(
108        ServiceOptions $options,
109        DefaultOptionsLookup $defaultOptionsLookup,
110        LanguageConverterFactory $languageConverterFactory,
111        IConnectionProvider $dbProvider,
112        LoggerInterface $logger,
113        HookContainer $hookContainer,
114        UserFactory $userFactory,
115        UserNameUtils $userNameUtils,
116        ObjectFactory $objectFactory
117    ) {
118        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
119        $this->serviceOptions = $options;
120        $this->defaultOptionsLookup = $defaultOptionsLookup;
121        $this->languageConverterFactory = $languageConverterFactory;
122        $this->dbProvider = $dbProvider;
123        $this->logger = $logger;
124        $this->hookRunner = new HookRunner( $hookContainer );
125        $this->userFactory = $userFactory;
126        $this->userNameUtils = $userNameUtils;
127        $this->objectFactory = $objectFactory;
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    public function isOptionGlobal( UserIdentity $user, string $key ) {
209        $this->getOptions( $user );
210        $source = $this->cache[ $this->getCacheKey( $user ) ]->sources[$key] ?? 'local';
211        return $source !== 'local';
212    }
213
214    /**
215     * Set the given option for a user.
216     *
217     * You need to call saveOptions() to actually write to the database.
218     *
219     * $val should be null or a string. Other types are accepted for B/C with legacy
220     * code but can result in surprising behavior and are discouraged. Values are always
221     * stored as strings in the database, so if you pass a non-string value, it will be
222     * eventually converted; but before the call to saveOptions(), getOption() will return
223     * the passed value from instance cache without any type conversion.
224     *
225     * A null value means resetting the option to its default value (removing the user_properties
226     * row). Passing in the same value as the default value fo the user has the same result.
227     * This behavior supports some level of type juggling - e.g. if the default value is 1,
228     * and you pass in '1', the option will be reset to its default value.
229     *
230     * When an option is reset to its default value, that means whenever the default value
231     * is changed in the site configuration, the user preference for this user will also change.
232     * There is no way to set a user preference to be the same as the default but avoid it
233     * changing when the default changes. You can instead use $wgConditionalUserOptions to
234     * split the default based on user registration date.
235     *
236     * If a global user option exists with the given name, the behaviour depends on the value
237     * of $global.
238     *
239     * @param UserIdentity $user
240     * @param string $oname The option to set
241     * @param mixed $val New value to set.
242     * @param string $global Since 1.43. What to do if the option was set
243     *   globally using the GlobalPreferences extension. One of the
244     *   self::GLOBAL_* constants:
245     *   - GLOBAL_IGNORE: Do nothing. The option remains with its previous value.
246     *   - GLOBAL_OVERRIDE: Add a local override.
247     *   - GLOBAL_UPDATE: Update the option globally.
248     *   The UI should typically ask for the user's consent before setting a global
249     *   option.
250     */
251    public function setOption( UserIdentity $user, string $oname, $val,
252        $global = self::GLOBAL_IGNORE
253    ) {
254        // Explicitly NULL values should refer to defaults
255        if ( $val === null ) {
256            $val = $this->defaultOptionsLookup->getDefaultOption( $oname, $user );
257        }
258        $userKey = $this->getCacheKey( $user );
259        $info = $this->cache[$userKey] ??= new UserOptionsCacheEntry;
260        $info->modifiedValues[$oname] = $val;
261        $info->globalUpdateActions[$oname] = $global;
262    }
263
264    /**
265     * Reset certain (or all) options to the site defaults
266     *
267     * The optional parameter determines which kinds of preferences will be reset.
268     * Supported values are everything that can be reported by getOptionKinds()
269     * and 'all', which forces a reset of *all* preferences and overrides everything else.
270     *
271     * @note You need to call saveOptions() to actually write to the database.
272     *
273     * @deprecated since 1.43 use resetOptionsByName() with PreferencesFactory::getOptionNamesForReset()
274     *
275     * @param UserIdentity $user
276     * @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
277     * @param array|string $resetKinds Which kinds of preferences to reset.
278     *  Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
279     */
280    public function resetOptions(
281        UserIdentity $user,
282        IContextSource $context,
283        $resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
284    ) {
285        wfDeprecated( __METHOD__, '1.43' );
286        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
287        $optionsToReset = $preferencesFactory->getOptionNamesForReset(
288            $this->userFactory->newFromUserIdentity( $user ), $context, $resetKinds );
289        $this->resetOptionsByName( $user, $optionsToReset );
290    }
291
292    /**
293     * Reset a list of options to the site defaults
294     *
295     * @note You need to call saveOptions() to actually write to the database.
296     *
297     * @param UserIdentity $user
298     * @param string[] $optionNames
299     */
300    public function resetOptionsByName(
301        UserIdentity $user,
302        array $optionNames
303    ) {
304        foreach ( $optionNames as $name ) {
305            $this->setOption( $user, $name, null );
306        }
307    }
308
309    /**
310     * Reset all options that were set to a non-default value by the given user
311     *
312     * @note You need to call saveOptions() to actually write to the database.
313     *
314     * @param UserIdentity $user
315     */
316    public function resetAllOptions( UserIdentity $user ) {
317        foreach ( $this->loadUserOptions( $user ) as $name => $value ) {
318            $this->setOption( $user, $name, null );
319        }
320    }
321
322    /**
323     * @deprecated since 1.43 use PreferencesFactory::listResetKinds()
324     *
325     * @return string[] Option kinds
326     */
327    public function listOptionKinds(): array {
328        wfDeprecated( __METHOD__, '1.43' );
329        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
330        return $preferencesFactory->listResetKinds();
331    }
332
333    /**
334     * @deprecated since 1.43 use PreferencesFactory::getResetKinds
335     *
336     * @param UserIdentity $userIdentity
337     * @param IContextSource $context
338     * @param array|null $options
339     * @return string[]
340     */
341    public function getOptionKinds(
342        UserIdentity $userIdentity,
343        IContextSource $context,
344        $options = null
345    ): array {
346        wfDeprecated( __METHOD__, '1.43' );
347        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
348        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
349        return $preferencesFactory->getResetKinds( $user, $context, $options );
350    }
351
352    /**
353     * Saves the non-default options for this user, as previously set e.g. via
354     * setOption(), in the database's "user_properties" (preferences) table.
355     *
356     * @since 1.38, this method was internal before that.
357     * @param UserIdentity $user
358     */
359    public function saveOptions( UserIdentity $user ) {
360        $dbw = $this->dbProvider->getPrimaryDatabase();
361        $changed = $this->saveOptionsInternal( $user );
362        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
363        // Before UserOptionsManager, User::saveSettings was used for user options
364        // saving. Some extensions might depend on UserSaveSettings hook being run
365        // when options are saved, so run this hook for legacy reasons.
366        // Once UserSaveSettings hook is deprecated and replaced with a different hook
367        // with more modern interface, extensions should use 'SaveUserOptions' hook.
368        $this->hookRunner->onUserSaveSettings( $legacyUser );
369        if ( $changed ) {
370            $dbw->onTransactionCommitOrIdle( static function () use ( $legacyUser ) {
371                $legacyUser->checkAndSetTouched();
372            }, __METHOD__ );
373        }
374    }
375
376    /**
377     * Saves the non-default options for this user, as previously set e.g. via
378     * setOption(), in the database's "user_properties" (preferences) table.
379     *
380     * @param UserIdentity $user
381     * @return bool true if options were changed and new options successfully saved.
382     * @internal only public for use in User::saveSettings
383     */
384    public function saveOptionsInternal( UserIdentity $user ): bool {
385        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
386            throw new InvalidArgumentException( __METHOD__ . ' was called on anon or temporary user' );
387        }
388
389        $userKey = $this->getCacheKey( $user );
390        $cache = $this->cache[$userKey] ?? new UserOptionsCacheEntry;
391        $modifiedOptions = $cache->modifiedValues;
392
393        // FIXME: should probably use READ_LATEST here
394        $originalOptions = $this->loadOriginalOptions( $user );
395
396        if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) {
397            return false;
398        }
399
400        $updatesByStore = [];
401        foreach ( $modifiedOptions as $key => $value ) {
402            // Don't store unchanged or default values
403            $defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key, $user );
404            if ( $value === null || self::isValueEqual( $value, $defaultValue ) ) {
405                $valOrNull = null;
406            } else {
407                $valOrNull = (string)$value;
408            }
409            $source = $cache->sources[$key] ?? 'local';
410            if ( $source === 'local' ) {
411                $updatesByStore['local'][$key] = $valOrNull;
412            } else {
413                $updateAction = $cache->globalUpdateActions[$key] ?? self::GLOBAL_IGNORE;
414                if ( $updateAction === self::GLOBAL_UPDATE ) {
415                    $updatesByStore[$source][$key] = $valOrNull;
416                } elseif ( $updateAction === self::GLOBAL_OVERRIDE ) {
417                    $updatesByStore['local'][$key] = $valOrNull;
418                    $updatesByStore['local'][$key . self::LOCAL_EXCEPTION_SUFFIX] = '1';
419                }
420            }
421        }
422        $changed = false;
423        $stores = $this->getStores();
424        foreach ( $updatesByStore as $source => $updates ) {
425            $changed = $stores[$source]->store( $user, $updates ) || $changed;
426        }
427
428        if ( !$changed ) {
429            return false;
430        }
431
432        // Clear the cache and the update queue
433        unset( $this->cache[$userKey] );
434        return true;
435    }
436
437    /**
438     * Loads user options either from cache or from the database.
439     *
440     * @note Query flags are ignored for anons, since they do not have any
441     * options stored in the database. If the UserIdentity was itself
442     * obtained from a replica and doesn't have ID set due to replication lag,
443     * it will be treated as anon regardless of the query flags passed here.
444     *
445     * @internal
446     *
447     * @param UserIdentity $user
448     * @param int $queryFlags
449     * @return array
450     */
451    public function loadUserOptions(
452        UserIdentity $user,
453        int $queryFlags = IDBAccessObject::READ_NORMAL
454    ): array {
455        $userKey = $this->getCacheKey( $user );
456        $originalOptions = $this->loadOriginalOptions( $user, $queryFlags );
457        $cache = $this->cache[$userKey] ?? null;
458        if ( $cache ) {
459            return array_merge( $originalOptions, $cache->modifiedValues );
460        } else {
461            return $originalOptions;
462        }
463    }
464
465    /**
466     * Clears cached user options.
467     * @internal To be used by User::clearInstanceCache
468     * @param UserIdentity $user
469     */
470    public function clearUserOptionsCache( UserIdentity $user ) {
471        unset( $this->cache[ $this->getCacheKey( $user ) ] );
472    }
473
474    /**
475     * Fetches the options directly from the database with no caches.
476     *
477     * @param UserIdentity $user
478     * @param int $queryFlags a bit field composed of READ_XXX flags
479     * @return array
480     */
481    private function loadOptionsFromStore(
482        UserIdentity $user,
483        int $queryFlags
484    ): array {
485        $this->logger->debug( 'Loading options from database', [ 'user_id' => $user->getId() ] );
486        $mergedOptions = [];
487        $cache = $this->cache[ $this->getCacheKey( $user ) ] ??= new UserOptionsCacheEntry;
488        foreach ( $this->getStores() as $storeName => $store ) {
489            $options = $store->fetch( $user, $queryFlags );
490            foreach ( $options as $name => $value ) {
491                // Handle a local exception which is the default
492                if ( str_ends_with( $name, self::LOCAL_EXCEPTION_SUFFIX ) && $value ) {
493                    $baseName = substr( $name, 0, -strlen( self::LOCAL_EXCEPTION_SUFFIX ) );
494                    if ( !isset( $options[$baseName] ) ) {
495                        unset( $mergedOptions[$baseName] );
496                    }
497                }
498
499                // Handle a non-default option or non-default local exception
500                if ( !isset( $mergedOptions[$name] )
501                    || !empty( $options[$name . self::LOCAL_EXCEPTION_SUFFIX] )
502                ) {
503                    $cache->sources[$name] = $storeName;
504                    $mergedOptions[$name] = $this->normalizeValueType( $value );
505                }
506            }
507        }
508        return $mergedOptions;
509    }
510
511    /**
512     * Convert '0' to 0. PHP's boolean conversion considers them both
513     * false, but e.g. JavaScript considers the former as true.
514     *
515     * @todo T54542 Somehow determine the desired type (string/int/bool)
516     *   and convert all values here.
517     *
518     * @param string $value
519     * @return mixed
520     */
521    private function normalizeValueType( $value ) {
522        if ( $value === '0' ) {
523            $value = 0;
524        }
525        return $value;
526    }
527
528    /**
529     * Loads the original user options from the database and applies various transforms,
530     * like timecorrection. Runs hooks.
531     *
532     * @param UserIdentity $user
533     * @param int $queryFlags
534     * @return array
535     */
536    private function loadOriginalOptions(
537        UserIdentity $user,
538        int $queryFlags = IDBAccessObject::READ_NORMAL
539    ): array {
540        $userKey = $this->getCacheKey( $user );
541        $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user );
542        $cache = $this->cache[$userKey] ??= new UserOptionsCacheEntry;
543        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
544            // For unlogged-in users, load language/variant options from request.
545            // There's no need to do it for logged-in users: they can set preferences,
546            // and handling of page content is done by $pageLang->getPreferredVariant() and such,
547            // so don't override user's choice (especially when the user chooses site default).
548            $variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant();
549            $defaultOptions['variant'] = $variant;
550            $defaultOptions['language'] = $variant;
551            $cache->originalValues = $defaultOptions;
552            return $defaultOptions;
553        }
554
555        // In case options were already loaded from the database before and no options
556        // changes were saved to the database, we can use the cached original options.
557        if ( $cache->canUseCachedValues( $queryFlags )
558            && $cache->originalValues !== null
559        ) {
560            return $cache->originalValues;
561        }
562
563        $options = $this->loadOptionsFromStore( $user, $queryFlags ) + $defaultOptions;
564
565        // Replace deprecated language codes
566        $options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] );
567        $options['variant'] = LanguageCode::replaceDeprecatedCodes( $options['variant'] );
568        foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
569            $variant = "variant-$langCode";
570            if ( isset( $options[$variant] ) ) {
571                $options[$variant] = LanguageCode::replaceDeprecatedCodes( $options[$variant] );
572            }
573        }
574
575        // Fix up timezone offset (Due to DST it can change from what was stored in the DB)
576        // ZoneInfo|offset|TimeZoneName
577        if ( isset( $options['timecorrection'] ) ) {
578            $options['timecorrection'] = ( new UserTimeCorrection(
579                $options['timecorrection'],
580                null,
581                $this->serviceOptions->get( MainConfigNames::LocalTZoffset )
582            ) )->toString();
583        }
584
585        // Need to store what we have so far before the hook to prevent
586        // infinite recursion if the hook attempts to reload options
587        $cache->originalValues = $options;
588        $cache->recency = $queryFlags;
589        $this->hookRunner->onLoadUserOptions( $user, $options );
590        $cache->originalValues = $options;
591        return $options;
592    }
593
594    /**
595     * Get a cache key for a user
596     * @param UserIdentity $user
597     * @return string
598     */
599    private function getCacheKey( UserIdentity $user ): string {
600        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
601            return 'anon';
602        } else {
603            return "u:{$user->getId()}";
604        }
605    }
606
607    /**
608     * Determines whether two values are sufficiently similar that the database
609     * does not need to be updated to reflect the change. This is basically the
610     * same as comparing the result of Database::addQuotes().
611     *
612     * @since 1.43
613     *
614     * @param mixed $a
615     * @param mixed $b
616     * @return bool
617     */
618    public static function isValueEqual( $a, $b ) {
619        // null is only equal to another null (T355086)
620        if ( $a === null || $b === null ) {
621            return $a === $b;
622        }
623
624        if ( is_bool( $a ) ) {
625            $a = (int)$a;
626        }
627        if ( is_bool( $b ) ) {
628            $b = (int)$b;
629        }
630        return (string)$a === (string)$b;
631    }
632
633    /**
634     * Get the storage backends in descending order of priority
635     *
636     * @return UserOptionsStore[]
637     */
638    private function getStores() {
639        if ( !$this->stores ) {
640            $stores = [ 'local' => new LocalUserOptionsStore( $this->dbProvider ) ];
641            $specs = ExtensionRegistry::getInstance()
642                ->getAttribute( 'UserOptionsStoreProviders' );
643            foreach ( $specs as $name => $spec ) {
644                $store = $this->objectFactory->createObject( $spec );
645                if ( !$store instanceof UserOptionsStore ) {
646                    throw new \RuntimeException( "Invalid type for extension store \"$name\"" );
647                }
648                $stores[$name] = $store;
649            }
650            // Query global providers first, preserve keys
651            $this->stores = array_reverse( $stores, true );
652        }
653        return $this->stores;
654    }
655}
656
657/** @deprecated class alias since 1.42 */
658class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' );