Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.41% covered (warning)
67.41%
151 / 224
57.89% covered (warning)
57.89%
11 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserOptionsManager
67.71% covered (warning)
67.71%
151 / 223
57.89% covered (warning)
57.89%
11 / 19
348.64
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
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
 setOption
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resetOptions
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
11.46
 listOptionKinds
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getOptionKinds
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
506
 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%
40 / 40
100.00% covered (success)
100.00%
1 / 1
14
 loadUserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 clearUserOptionsCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 loadOptionsFromDb
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
3.47
 setOptionsFromDb
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 loadOriginalOptions
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
8.33
 getCacheKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 canUseCachedValues
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isValueEqual
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
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 DBAccessObjectUtils;
24use HTMLCheckMatrix;
25use HTMLMultiSelectField;
26use IDBAccessObject;
27use InvalidArgumentException;
28use LanguageCode;
29use LanguageConverter;
30use MediaWiki\Config\ServiceOptions;
31use MediaWiki\Context\IContextSource;
32use MediaWiki\HookContainer\HookContainer;
33use MediaWiki\HookContainer\HookRunner;
34use MediaWiki\HTMLForm\HTMLFormField;
35use MediaWiki\Languages\LanguageConverterFactory;
36use MediaWiki\MainConfigNames;
37use MediaWiki\MediaWikiServices;
38use MediaWiki\User\UserFactory;
39use MediaWiki\User\UserIdentity;
40use MediaWiki\User\UserNameUtils;
41use MediaWiki\User\UserTimeCorrection;
42use Psr\Log\LoggerInterface;
43use Wikimedia\Rdbms\IConnectionProvider;
44use Wikimedia\Rdbms\IDatabase;
45
46/**
47 * A service class to control user options
48 * @since 1.35
49 */
50class UserOptionsManager extends UserOptionsLookup {
51
52    /**
53     * @internal For use by ServiceWiring
54     */
55    public const CONSTRUCTOR_OPTIONS = [
56        MainConfigNames::HiddenPrefs,
57        MainConfigNames::LocalTZoffset,
58    ];
59
60    /**
61     * @since 1.39.5, 1.40
62     */
63    public const MAX_BYTES_OPTION_VALUE = 65530;
64
65    private ServiceOptions $serviceOptions;
66    private DefaultOptionsLookup $defaultOptionsLookup;
67    private LanguageConverterFactory $languageConverterFactory;
68    private IConnectionProvider $dbProvider;
69    private UserFactory $userFactory;
70    private LoggerInterface $logger;
71
72    /** @var array options modified within this request */
73    private $modifiedOptions = [];
74
75    /**
76     * @var array Cached original user options with all the adjustments
77     *            like time correction and hook changes applied.
78     *            Ready to be returned.
79     */
80    private $originalOptionsCache = [];
81
82    /**
83     * @var array Cached original user options as fetched from database,
84     *            no adjustments applied.
85     */
86    private $optionsFromDb = [];
87
88    private HookRunner $hookRunner;
89
90    /** @var array Query flags used to retrieve options from database */
91    private $queryFlagsUsedForCaching = [];
92
93    private UserNameUtils $userNameUtils;
94
95    /**
96     * @param ServiceOptions $options
97     * @param DefaultOptionsLookup $defaultOptionsLookup
98     * @param LanguageConverterFactory $languageConverterFactory
99     * @param IConnectionProvider $dbProvider
100     * @param LoggerInterface $logger
101     * @param HookContainer $hookContainer
102     * @param UserFactory $userFactory
103     * @param UserNameUtils $userNameUtils
104     */
105    public function __construct(
106        ServiceOptions $options,
107        DefaultOptionsLookup $defaultOptionsLookup,
108        LanguageConverterFactory $languageConverterFactory,
109        IConnectionProvider $dbProvider,
110        LoggerInterface $logger,
111        HookContainer $hookContainer,
112        UserFactory $userFactory,
113        UserNameUtils $userNameUtils
114    ) {
115        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
116        $this->serviceOptions = $options;
117        $this->defaultOptionsLookup = $defaultOptionsLookup;
118        $this->languageConverterFactory = $languageConverterFactory;
119        $this->dbProvider = $dbProvider;
120        $this->logger = $logger;
121        $this->hookRunner = new HookRunner( $hookContainer );
122        $this->userFactory = $userFactory;
123        $this->userNameUtils = $userNameUtils;
124    }
125
126    /**
127     * @inheritDoc
128     */
129    public function getDefaultOptions( ?UserIdentity $userIdentity = null ): array {
130        return $this->defaultOptionsLookup->getDefaultOptions( $userIdentity );
131    }
132
133    /**
134     * @inheritDoc
135     */
136    public function getDefaultOption( string $opt, ?UserIdentity $userIdentity = null ) {
137        return $this->defaultOptionsLookup->getDefaultOption( $opt, $userIdentity );
138    }
139
140    /**
141     * @inheritDoc
142     */
143    public function getOption(
144        UserIdentity $user,
145        string $oname,
146        $defaultOverride = null,
147        bool $ignoreHidden = false,
148        int $queryFlags = IDBAccessObject::READ_NORMAL
149    ) {
150        # We want 'disabled' preferences to always behave as the default value for
151        # users, even if they have set the option explicitly in their settings (ie they
152        # set it, and then it was disabled removing their ability to change it).  But
153        # we don't want to erase the preferences in the database in case the preference
154        # is re-enabled again.  So don't touch $mOptions, just override the returned value
155        if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) ) ) {
156            return $this->defaultOptionsLookup->getDefaultOption( $oname, $user );
157        }
158
159        $options = $this->loadUserOptions( $user, $queryFlags );
160        if ( array_key_exists( $oname, $options ) ) {
161            return $options[$oname];
162        }
163        return $defaultOverride;
164    }
165
166    /**
167     * @inheritDoc
168     */
169    public function getOptions(
170        UserIdentity $user,
171        int $flags = 0,
172        int $queryFlags = IDBAccessObject::READ_NORMAL
173    ): array {
174        $options = $this->loadUserOptions( $user, $queryFlags );
175
176        # We want 'disabled' preferences to always behave as the default value for
177        # users, even if they have set the option explicitly in their settings (ie they
178        # set it, and then it was disabled removing their ability to change it).  But
179        # we don't want to erase the preferences in the database in case the preference
180        # is re-enabled again.  So don't touch $mOptions, just override the returned value
181        foreach ( $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) as $pref ) {
182            $default = $this->defaultOptionsLookup->getDefaultOption( $pref, $user );
183            if ( $default !== null ) {
184                $options[$pref] = $default;
185            }
186        }
187
188        if ( $flags & self::EXCLUDE_DEFAULTS ) {
189            // NOTE: This intentionally ignores conditional defaults, so that `mw.user.options`
190            // work correctly for options with conditional defaults.
191            $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( null );
192            foreach ( $options as $option => $value ) {
193                if ( array_key_exists( $option, $defaultOptions )
194                    && $this->isValueEqual( $value, $defaultOptions[$option] )
195                ) {
196                    unset( $options[$option] );
197                }
198            }
199        }
200
201        return $options;
202    }
203
204    /**
205     * Set the given option for a user.
206     *
207     * You need to call saveOptions() to actually write to the database.
208     *
209     * $val should be null or a string. Other types are accepted for B/C with legacy
210     * code but can result in surprising behavior and are discouraged. Values are always
211     * stored as strings in the database, so if you pass a non-string value, it will be
212     * eventually converted; but before the call to saveOptions(), getOption() will return
213     * the passed value from instance cache without any type conversion.
214     *
215     * A null value means resetting the option to its default value (removing the user_properties
216     * row). Passing in the same value as the default value fo the user has the same result.
217     * This behavior supports some level of type juggling - e.g. if the default value is 1,
218     * and you pass in '1', the option will be reset to its default value.
219     *
220     * When an option is reset to its default value, that means whenever the default value
221     * is changed in the site configuration, the user preference for this user will also change.
222     * There is no way to set a user preference to be the same as the default but avoid it
223     * changing when the default changes. You can instead use $wgConditionalUserOptions to
224     * split the default based on user registration date.
225     *
226     * @param UserIdentity $user
227     * @param string $oname The option to set
228     * @param mixed $val New value to set.
229     */
230    public function setOption( UserIdentity $user, string $oname, $val ) {
231        // Explicitly NULL values should refer to defaults
232        if ( $val === null ) {
233            $val = $this->defaultOptionsLookup->getDefaultOption( $oname, $user );
234        }
235        $this->modifiedOptions[$this->getCacheKey( $user )][$oname] = $val;
236    }
237
238    /**
239     * Reset certain (or all) options to the site defaults
240     *
241     * The optional parameter determines which kinds of preferences will be reset.
242     * Supported values are everything that can be reported by getOptionKinds()
243     * and 'all', which forces a reset of *all* preferences and overrides everything else.
244     *
245     * @note You need to call saveOptions() to actually write to the database.
246     *
247     * @param UserIdentity $user
248     * @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
249     * @param array|string $resetKinds Which kinds of preferences to reset.
250     *  Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
251     */
252    public function resetOptions(
253        UserIdentity $user,
254        IContextSource $context,
255        $resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
256    ) {
257        $oldOptions = $this->loadUserOptions( $user, IDBAccessObject::READ_LATEST );
258        $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user );
259
260        if ( !is_array( $resetKinds ) ) {
261            $resetKinds = [ $resetKinds ];
262        }
263
264        if ( in_array( 'all', $resetKinds ) ) {
265            $newOptions = $defaultOptions + array_fill_keys( array_keys( $oldOptions ), null );
266        } else {
267            $optionKinds = $this->getOptionKinds( $user, $context );
268            $resetKinds = array_intersect( $resetKinds, $this->listOptionKinds() );
269            $newOptions = [];
270
271            // Use default values for the options that should be deleted, and
272            // copy old values for the ones that shouldn't.
273            foreach ( $oldOptions as $key => $value ) {
274                if ( in_array( $optionKinds[$key], $resetKinds ) ) {
275                    if ( array_key_exists( $key, $defaultOptions ) ) {
276                        $newOptions[$key] = $defaultOptions[$key];
277                    }
278                } else {
279                    $newOptions[$key] = $value;
280                }
281            }
282        }
283        $this->modifiedOptions[$this->getCacheKey( $user )] = $newOptions;
284    }
285
286    /**
287     * Return a list of the types of user options currently returned by
288     * UserOptionsManager::getOptionKinds().
289     *
290     * Currently, the option kinds are:
291     * - 'registered' - preferences which are registered in core MediaWiki or
292     *                  by extensions using the UserGetDefaultOptions hook.
293     * - 'registered-multiselect' - as above, using the 'multiselect' type.
294     * - 'registered-checkmatrix' - as above, using the 'checkmatrix' type.
295     * - 'userjs' - preferences with names starting with 'userjs-', intended to
296     *              be used by user scripts.
297     * - 'special' - "preferences" that are not accessible via
298     *              UserOptionsLookup::getOptions or UserOptionsManager::setOptions.
299     * - 'unused' - preferences about which MediaWiki doesn't know anything.
300     *              These are usually legacy options, removed in newer versions.
301     *
302     * The API (and possibly others) use this function to determine the possible
303     * option types for validation purposes, so make sure to update this when a
304     * new option kind is added.
305     *
306     * @see getOptionKinds
307     * @return string[] Option kinds
308     */
309    public function listOptionKinds(): array {
310        return [
311            'registered',
312            'registered-multiselect',
313            'registered-checkmatrix',
314            'userjs',
315            'special',
316            'unused'
317        ];
318    }
319
320    /**
321     * Return an associative array mapping preferences keys to the kind of a preference they're
322     * used for. Different kinds are handled differently when setting or reading preferences.
323     *
324     * See UserOptionsManager::listOptionKinds for the list of valid option types that can be provided.
325     *
326     * @see UserOptionsManager::listOptionKinds
327     * @param UserIdentity $userIdentity
328     * @param IContextSource $context
329     * @param array|null $options Assoc. array with options keys to check as keys.
330     *   Defaults user options.
331     * @return string[] The key => kind mapping data
332     */
333    public function getOptionKinds(
334        UserIdentity $userIdentity,
335        IContextSource $context,
336        $options = null
337    ): array {
338        if ( $options === null ) {
339            $options = $this->loadUserOptions( $userIdentity );
340        }
341
342        // TODO: injecting the preferences factory creates a cyclic dependency between
343        // PreferencesFactory and UserOptionsManager. See T250822
344        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
345        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
346        $prefs = $preferencesFactory->getFormDescriptor( $user, $context );
347        $mapping = [];
348
349        // Pull out the "special" options, so they don't get converted as
350        // multiselect or checkmatrix.
351        $specialOptions = array_fill_keys( $preferencesFactory->getSaveBlacklist(), true );
352        foreach ( $specialOptions as $name => $value ) {
353            unset( $prefs[$name] );
354        }
355
356        // Multiselect and checkmatrix options are stored in the database with
357        // one key per option, each having a boolean value. Extract those keys.
358        $multiselectOptions = [];
359        foreach ( $prefs as $name => $info ) {
360            if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
361                ( isset( $info['class'] ) && $info['class'] == HTMLMultiSelectField::class )
362            ) {
363                $opts = HTMLFormField::flattenOptions( $info['options'] ?? $info['options-messages'] );
364                $prefix = $info['prefix'] ?? $name;
365
366                foreach ( $opts as $value ) {
367                    $multiselectOptions["$prefix$value"] = true;
368                }
369
370                unset( $prefs[$name] );
371            }
372        }
373        $checkmatrixOptions = [];
374        foreach ( $prefs as $name => $info ) {
375            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
376                ( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class )
377            ) {
378                $columns = HTMLFormField::flattenOptions( $info['columns'] );
379                $rows = HTMLFormField::flattenOptions( $info['rows'] );
380                $prefix = $info['prefix'] ?? $name;
381
382                foreach ( $columns as $column ) {
383                    foreach ( $rows as $row ) {
384                        $checkmatrixOptions["$prefix$column-$row"] = true;
385                    }
386                }
387
388                unset( $prefs[$name] );
389            }
390        }
391
392        // $value is ignored
393        foreach ( $options as $key => $value ) {
394            if ( isset( $prefs[$key] ) ) {
395                $mapping[$key] = 'registered';
396            } elseif ( isset( $multiselectOptions[$key] ) ) {
397                $mapping[$key] = 'registered-multiselect';
398            } elseif ( isset( $checkmatrixOptions[$key] ) ) {
399                $mapping[$key] = 'registered-checkmatrix';
400            } elseif ( isset( $specialOptions[$key] ) ) {
401                $mapping[$key] = 'special';
402            } elseif ( str_starts_with( $key, 'userjs-' ) ) {
403                $mapping[$key] = 'userjs';
404            } else {
405                $mapping[$key] = 'unused';
406            }
407        }
408
409        return $mapping;
410    }
411
412    /**
413     * Saves the non-default options for this user, as previously set e.g. via
414     * setOption(), in the database's "user_properties" (preferences) table.
415     *
416     * @since 1.38, this method was internal before that.
417     * @param UserIdentity $user
418     */
419    public function saveOptions( UserIdentity $user ) {
420        $dbw = $this->dbProvider->getPrimaryDatabase();
421        $changed = $this->saveOptionsInternal( $user, $dbw );
422        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
423        // Before UserOptionsManager, User::saveSettings was used for user options
424        // saving. Some extensions might depend on UserSaveSettings hook being run
425        // when options are saved, so run this hook for legacy reasons.
426        // Once UserSaveSettings hook is deprecated and replaced with a different hook
427        // with more modern interface, extensions should use 'SaveUserOptions' hook.
428        $this->hookRunner->onUserSaveSettings( $legacyUser );
429        if ( $changed ) {
430            $dbw->onTransactionCommitOrIdle( static function () use ( $legacyUser ) {
431                $legacyUser->checkAndSetTouched();
432            }, __METHOD__ );
433        }
434    }
435
436    /**
437     * Saves the non-default options for this user, as previously set e.g. via
438     * setOption(), in the database's "user_properties" (preferences) table.
439     *
440     * @param UserIdentity $user
441     * @param IDatabase $dbw
442     * @return bool true if options were changed and new options successfully saved.
443     * @internal only public for use in User::saveSettings
444     */
445    public function saveOptionsInternal( UserIdentity $user, IDatabase $dbw ): bool {
446        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
447            throw new InvalidArgumentException( __METHOD__ . ' was called on anon or temporary user' );
448        }
449
450        $userKey = $this->getCacheKey( $user );
451        $modifiedOptions = $this->modifiedOptions[$userKey] ?? [];
452        $originalOptions = $this->loadOriginalOptions( $user );
453        if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) {
454            return false;
455        }
456
457        $rowsToInsert = [];
458        $keysToDelete = [];
459        foreach ( $modifiedOptions as $key => $value ) {
460            // Don't store unchanged or default values
461            $defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key, $user );
462            $oldDbValue = $this->optionsFromDb[$userKey][$key] ?? null;
463            if ( $value === null || $this->isValueEqual( $value, $defaultValue ) ) {
464                if ( array_key_exists( $key, $this->optionsFromDb[$userKey] ) ) {
465                    // Delete the default value from the database
466                    $keysToDelete[] = $key;
467                }
468            } elseif ( !$this->isValueEqual( $value, $oldDbValue ) ) {
469                // Update by deleting (if old value exists) and reinserting
470                $rowsToInsert[] = [
471                    'up_user' => $user->getId(),
472                    'up_property' => $key,
473                    'up_value' => mb_strcut( $value, 0, self::MAX_BYTES_OPTION_VALUE ),
474                ];
475                if ( array_key_exists( $key, $this->optionsFromDb[$userKey] ) ) {
476                    $keysToDelete[] = $key;
477                }
478            }
479        }
480
481        if ( !count( $keysToDelete ) && !count( $rowsToInsert ) ) {
482            // Nothing to do
483            return false;
484        }
485
486        // Do the DELETE
487        if ( $keysToDelete ) {
488            $dbw->newDeleteQueryBuilder()
489                ->deleteFrom( 'user_properties' )
490                ->where( [ 'up_user' => $user->getId() ] )
491                ->andWhere( [ 'up_property' => $keysToDelete ] )
492                ->caller( __METHOD__ )->execute();
493        }
494        if ( $rowsToInsert ) {
495            // Insert the new preference rows
496            $dbw->newInsertQueryBuilder()
497                ->insertInto( 'user_properties' )
498                ->ignore()
499                ->rows( $rowsToInsert )
500                ->caller( __METHOD__ )->execute();
501        }
502
503        // It's pretty cheap to recalculate new original later
504        // to apply whatever adjustments we apply when fetching from DB
505        // and re-merge with the defaults.
506        unset( $this->originalOptionsCache[$userKey] );
507        // And nothing is modified anymore
508        unset( $this->modifiedOptions[$userKey] );
509        return true;
510    }
511
512    /**
513     * Loads user options either from cache or from the database.
514     *
515     * @note Query flags are ignored for anons, since they do not have any
516     * options stored in the database. If the UserIdentity was itself
517     * obtained from a replica and doesn't have ID set due to replication lag,
518     * it will be treated as anon regardless of the query flags passed here.
519     *
520     * @param UserIdentity $user
521     * @param int $queryFlags
522     * @param array|null $data associative array of non-default options.
523     * @return array
524     * @internal To be called by User loading code to provide the $data
525     */
526    public function loadUserOptions(
527        UserIdentity $user,
528        int $queryFlags = IDBAccessObject::READ_NORMAL,
529        array $data = null
530    ): array {
531        $userKey = $this->getCacheKey( $user );
532        $originalOptions = $this->loadOriginalOptions( $user, $queryFlags, $data );
533        return array_merge( $originalOptions, $this->modifiedOptions[$userKey] ?? [] );
534    }
535
536    /**
537     * Clears cached user options.
538     * @internal To be used by User::clearInstanceCache
539     * @param UserIdentity $user
540     */
541    public function clearUserOptionsCache( UserIdentity $user ) {
542        $cacheKey = $this->getCacheKey( $user );
543        unset( $this->modifiedOptions[$cacheKey] );
544        unset( $this->optionsFromDb[$cacheKey] );
545        unset( $this->originalOptionsCache[$cacheKey] );
546        unset( $this->queryFlagsUsedForCaching[$cacheKey] );
547    }
548
549    /**
550     * Fetches the options directly from the database with no caches.
551     *
552     * @param UserIdentity $user
553     * @param int $queryFlags a bit field composed of READ_XXX flags
554     * @param array|null $prefetchedOptions
555     * @return array
556     */
557    private function loadOptionsFromDb(
558        UserIdentity $user,
559        int $queryFlags,
560        array $prefetchedOptions = null
561    ): array {
562        if ( $prefetchedOptions === null ) {
563            $this->logger->debug( 'Loading options from database', [ 'user_id' => $user->getId() ] );
564            $dbr = DBAccessObjectUtils::getDBFromRecency( $this->dbProvider, $queryFlags );
565            $res = $dbr->newSelectQueryBuilder()
566                ->select( [ 'up_property', 'up_value' ] )
567                ->from( 'user_properties' )
568                ->where( [ 'up_user' => $user->getId() ] )
569                ->recency( $queryFlags )
570                ->caller( __METHOD__ )->fetchResultSet();
571        } else {
572            $res = [];
573            foreach ( $prefetchedOptions as $name => $value ) {
574                $res[] = [
575                    'up_property' => $name,
576                    'up_value' => $value,
577                ];
578            }
579        }
580        return $this->setOptionsFromDb( $user, $queryFlags, $res );
581    }
582
583    /**
584     * Builds associative options array from rows fetched from DB.
585     *
586     * @param UserIdentity $user
587     * @param int $queryFlags
588     * @param iterable<object|array> $rows
589     * @return array
590     */
591    private function setOptionsFromDb(
592        UserIdentity $user,
593        int $queryFlags,
594        iterable $rows
595    ): array {
596        $userKey = $this->getCacheKey( $user );
597        $options = [];
598        foreach ( $rows as $row ) {
599            $row = (object)$row;
600            // Convert '0' to 0. PHP's boolean conversion considers them both
601            // false, but e.g. JavaScript considers the former as true.
602            // @todo: T54542 Somehow determine the desired type (string/int/bool)
603            //  and convert all values here.
604            if ( $row->up_value === '0' ) {
605                $row->up_value = 0;
606            }
607            $options[$row->up_property] = $row->up_value;
608        }
609        $this->optionsFromDb[$userKey] = $options;
610        $this->queryFlagsUsedForCaching[$userKey] = $queryFlags;
611        return $options;
612    }
613
614    /**
615     * Loads the original user options from the database and applies various transforms,
616     * like timecorrection. Runs hooks.
617     *
618     * @param UserIdentity $user
619     * @param int $queryFlags
620     * @param array|null $data associative array of non-default options
621     * @return array
622     */
623    private function loadOriginalOptions(
624        UserIdentity $user,
625        int $queryFlags = IDBAccessObject::READ_NORMAL,
626        array $data = null
627    ): array {
628        $userKey = $this->getCacheKey( $user );
629        $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user );
630        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
631            // For unlogged-in users, load language/variant options from request.
632            // There's no need to do it for logged-in users: they can set preferences,
633            // and handling of page content is done by $pageLang->getPreferredVariant() and such,
634            // so don't override user's choice (especially when the user chooses site default).
635            $variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant();
636            $defaultOptions['variant'] = $variant;
637            $defaultOptions['language'] = $variant;
638            $this->originalOptionsCache[$userKey] = $defaultOptions;
639            return $defaultOptions;
640        }
641
642        // In case options were already loaded from the database before and no options
643        // changes were saved to the database, we can use the cached original options.
644        if ( $this->canUseCachedValues( $user, $queryFlags )
645            && isset( $this->originalOptionsCache[$userKey] )
646        ) {
647            return $this->originalOptionsCache[$userKey];
648        }
649
650        $options = $this->loadOptionsFromDb( $user, $queryFlags, $data ) + $defaultOptions;
651
652        // Replace deprecated language codes
653        $options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] );
654        $options['variant'] = LanguageCode::replaceDeprecatedCodes( $options['variant'] );
655        foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
656            $variant = "variant-$langCode";
657            if ( isset( $options[$variant] ) ) {
658                $options[$variant] = LanguageCode::replaceDeprecatedCodes( $options[$variant] );
659            }
660        }
661
662        // Fix up timezone offset (Due to DST it can change from what was stored in the DB)
663        // ZoneInfo|offset|TimeZoneName
664        if ( isset( $options['timecorrection'] ) ) {
665            $options['timecorrection'] = ( new UserTimeCorrection(
666                $options['timecorrection'],
667                null,
668                $this->serviceOptions->get( MainConfigNames::LocalTZoffset )
669            ) )->toString();
670        }
671
672        // Need to store what we have so far before the hook to prevent
673        // infinite recursion if the hook attempts to reload options
674        $this->originalOptionsCache[$userKey] = $options;
675        $this->queryFlagsUsedForCaching[$userKey] = $queryFlags;
676        $this->hookRunner->onLoadUserOptions( $user, $options );
677        $this->originalOptionsCache[$userKey] = $options;
678        return $options;
679    }
680
681    /**
682     * Gets a key for various caches.
683     * @param UserIdentity $user
684     * @return string
685     */
686    private function getCacheKey( UserIdentity $user ): string {
687        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
688            return 'anon';
689        } else {
690            return "u:{$user->getId()}";
691        }
692    }
693
694    /**
695     * Determines if it's ok to use cached options values for a given user and query flags
696     * @param UserIdentity $user
697     * @param int $queryFlags
698     * @return bool
699     */
700    private function canUseCachedValues( UserIdentity $user, int $queryFlags ): bool {
701        if ( !$user->isRegistered() || $this->userNameUtils->isTemp( $user->getName() ) ) {
702            // Anon & temp users don't have options stored in the database,
703            // so $queryFlags are ignored.
704            return true;
705        }
706        $userKey = $this->getCacheKey( $user );
707        $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey] ?? IDBAccessObject::READ_NONE;
708        return $queryFlagsUsed >= $queryFlags;
709    }
710
711    /**
712     * Determines whether two values are sufficiently similar that the database
713     * does not need to be updated to reflect the change. This is basically the
714     * same as comparing the result of Database::addQuotes().
715     *
716     * @param mixed $a
717     * @param mixed $b
718     * @return bool
719     */
720    private function isValueEqual( $a, $b ) {
721        // null is only equal to another null (T355086)
722        if ( $a === null || $b === null ) {
723            return $a === $b;
724        }
725
726        if ( is_bool( $a ) ) {
727            $a = (int)$a;
728        }
729        if ( is_bool( $b ) ) {
730            $b = (int)$b;
731        }
732        return (string)$a === (string)$b;
733    }
734}
735
736/** @deprecated class alias since 1.41 */
737class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' );