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