Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
67.41% |
151 / 224 |
|
57.89% |
11 / 19 |
CRAP | |
0.00% |
0 / 1 |
UserOptionsManager | |
67.71% |
151 / 223 |
|
57.89% |
11 / 19 |
348.64 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getDefaultOptions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDefaultOption | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getOption | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
getOptions | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
7.03 | |||
setOption | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
resetOptions | |
46.67% |
7 / 15 |
|
0.00% |
0 / 1 |
11.46 | |||
listOptionKinds | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getOptionKinds | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
506 | |||
saveOptions | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
saveOptionsInternal | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
14 | |||
loadUserOptions | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
clearUserOptionsCache | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
loadOptionsFromDb | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
3.47 | |||
setOptionsFromDb | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
loadOriginalOptions | |
82.76% |
24 / 29 |
|
0.00% |
0 / 1 |
8.33 | |||
getCacheKey | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
canUseCachedValues | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
isValueEqual | |
100.00% |
7 / 7 |
|
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 | |
21 | namespace MediaWiki\User\Options; |
22 | |
23 | use DBAccessObjectUtils; |
24 | use HTMLCheckMatrix; |
25 | use HTMLMultiSelectField; |
26 | use IDBAccessObject; |
27 | use InvalidArgumentException; |
28 | use LanguageCode; |
29 | use LanguageConverter; |
30 | use MediaWiki\Config\ServiceOptions; |
31 | use MediaWiki\Context\IContextSource; |
32 | use MediaWiki\HookContainer\HookContainer; |
33 | use MediaWiki\HookContainer\HookRunner; |
34 | use MediaWiki\HTMLForm\HTMLFormField; |
35 | use MediaWiki\Languages\LanguageConverterFactory; |
36 | use MediaWiki\MainConfigNames; |
37 | use MediaWiki\MediaWikiServices; |
38 | use MediaWiki\User\UserFactory; |
39 | use MediaWiki\User\UserIdentity; |
40 | use MediaWiki\User\UserNameUtils; |
41 | use MediaWiki\User\UserTimeCorrection; |
42 | use Psr\Log\LoggerInterface; |
43 | use Wikimedia\Rdbms\IConnectionProvider; |
44 | use Wikimedia\Rdbms\IDatabase; |
45 | |
46 | /** |
47 | * A service class to control user options |
48 | * @since 1.35 |
49 | */ |
50 | class 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 */ |
737 | class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' ); |