MediaWiki master
userOptions.php
Go to the documentation of this file.
1<?php
18
19// @codeCoverageIgnoreStart
20require_once __DIR__ . '/Maintenance.php';
21// @codeCoverageIgnoreEnd
22
27
28 public function __construct() {
29 parent::__construct();
30
31 $this->addDescription( 'Pass through all users and change or delete one of their options.
32The new option is NOT validated.' );
33
34 $this->addOption( 'list', 'List available user options and their default value' );
35 $this->addOption( 'usage', 'Report all options statistics or just one if you specify it' );
36 $this->addOption(
37 'old',
38 'The value to look for. If it is a default value for the option, pass --old-is-default as well.',
39 false, true, false, true
40 );
41 $this->addOption( 'old-is-default', 'If passed, --old is interpreted as a default value.' );
42 $this->addOption( 'new', 'New value to update users with', false, true );
43 $this->addOption( 'delete', 'Delete the option instead of updating' );
44 $this->addOption( 'delete-defaults', 'Delete user_properties row matching the default' );
45 $this->addOption( 'fromuserid', 'Start from this user ID when changing/deleting options',
46 false, true );
47 $this->addOption( 'touserid', 'Do not go beyond this user ID when changing/deleting options',
48 false, true );
49 $this->addOption( 'nowarn', 'Hides the 5 seconds warning' );
50 $this->addOption( 'dry', 'Do not save user settings back to database' );
51 $this->addArg( 'option name', 'Name of the option to change or provide statistics about', false );
52 $this->setBatchSize( 100 );
53 }
54
58 public function execute() {
59 if ( $this->hasOption( 'list' ) ) {
60 $this->listAvailableOptions();
61 } elseif ( $this->hasOption( 'usage' ) ) {
62 $this->showUsageStats();
63 } elseif ( $this->hasOption( 'old' )
64 && $this->hasOption( 'new' )
65 && $this->hasArg( 0 )
66 ) {
67 $this->updateOptions();
68 } elseif ( $this->hasOption( 'delete' ) ) {
69 $this->deleteOptions();
70 } elseif ( $this->hasOption( 'delete-defaults' ) ) {
71 $this->deleteDefaults();
72 } else {
73 $this->maybeHelp( true );
74 }
75 }
76
80 private function listAvailableOptions() {
81 $userOptionsLookup = $this->getServiceContainer()->getUserOptionsLookup();
82 $def = $userOptionsLookup->getDefaultOptions( null );
83 ksort( $def );
84 $maxOpt = 0;
85 foreach ( $def as $opt => $value ) {
86 $maxOpt = max( $maxOpt, strlen( $opt ) );
87 }
88 foreach ( $def as $opt => $value ) {
89 $this->output( sprintf( "%-{$maxOpt}s: %s\n", $opt, $value ) );
90 }
91 }
92
96 private function showUsageStats() {
97 $option = $this->getArg( 0 );
98
99 $ret = [];
100 $userOptionsLookup = $this->getServiceContainer()->getUserOptionsLookup();
101 $defaultOptions = $userOptionsLookup->getDefaultOptions();
102
103 if ( $option && !array_key_exists( $option, $defaultOptions ) ) {
104 $this->fatalError( "Invalid user option. Use --list to see valid choices\n" );
105 }
106
107 // We list user by user_id from one of the replica DBs
108 $dbr = $this->getServiceContainer()->getConnectionProvider()->getReplicaDatabase();
109
110 $result = $dbr->newSelectQueryBuilder()
111 ->select( [ 'user_id' ] )
112 ->from( 'user' )
113 ->caller( __METHOD__ )->fetchResultSet();
114
115 foreach ( $result as $id ) {
116 $user = User::newFromId( $id->user_id );
117
118 // Get the options and update stats
119 if ( $option ) {
120 $userValue = $userOptionsLookup->getOption( $user, $option );
121 if ( $userValue != $defaultOptions[$option] ) {
122 $ret[$option][$userValue] = ( $ret[$option][$userValue] ?? 0 ) + 1;
123 }
124 } else {
125 foreach ( $defaultOptions as $name => $defaultValue ) {
126 $userValue = $userOptionsLookup->getOption( $user, $name );
127 if ( $userValue != $defaultValue ) {
128 $ret[$name][$userValue] = ( $ret[$name][$userValue] ?? 0 ) + 1;
129 }
130 }
131 }
132 }
133
134 foreach ( $ret as $optionName => $usageStats ) {
135 $this->output( "Usage for <$optionName> (default: '{$defaultOptions[$optionName]}'):\n" );
136 foreach ( $usageStats as $value => $count ) {
137 $this->output( " $count user(s): '$value'\n" );
138 }
139 print "\n";
140 }
141 }
142
146 private function updateOptions() {
147 $dryRun = $this->hasOption( 'dry' );
148 $settingWord = $dryRun ? 'Would set' : 'Setting';
149 $option = $this->getArg( 0 );
150 $fromIsDefault = $this->hasOption( 'old-is-default' );
151 $from = $this->getOption( 'old' );
152 $to = $this->getOption( 'new' );
153
154 // The fromuserid parameter is inclusive, but iterating is easier with an exclusive
155 // range so convert it.
156 $fromUserId = (int)$this->getOption( 'fromuserid', 1 ) - 1;
157 $toUserId = (int)$this->getOption( 'touserid', 0 ) ?: null;
158 $fromAsText = implode( '|', $from );
159
160 if ( !$dryRun ) {
161 $forUsers = ( $fromUserId || $toUserId ) ? "some users (ID $fromUserId-$toUserId)" : 'ALL USERS';
162 $this->warn(
163 <<<WARN
164The script is about to change the options for $forUsers in the database.
165Users with option <$option> = '$fromAsText' will be made to use '$to'.
166
167Abort with control-c in the next five seconds....
168WARN
169 );
170 }
171
172 $userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
173 $tempUserConfig = $this->getServiceContainer()->getTempUserConfig();
174 $dbr = $this->getReplicaDB();
175 $queryBuilderTemplate = $dbr->newSelectQueryBuilder()
176 ->table( 'user' )
177 ->leftJoin( 'user_properties', null, [
178 'user_id = up_user',
179 'up_property' => $option,
180 ] )
181 ->fields( [ 'user_id', 'user_name', 'up_value' ] )
182 // up_value is unindexed so this can be slow, but should be acceptable in a script
183 ->where( [ 'up_value' => $fromIsDefault ? null : $from ] )
184 // need to order by ID so we can use ID ranges for query continuation
185 // also needed for the fromuserid / touserid parameters to work
186 ->orderBy( 'user_id', SelectQueryBuilder::SORT_ASC )
187 ->limit( $this->getBatchSize() )
188 ->caller( __METHOD__ );
189 if ( $toUserId ) {
190 $queryBuilderTemplate->andWhere( $dbr->expr( 'user_id', '<=', $toUserId ) );
191 }
192
193 if ( $tempUserConfig->isKnown() ) {
194 $queryBuilderTemplate->andWhere(
195 $tempUserConfig->getMatchCondition( $dbr, 'user_name', IExpression::NOT_LIKE )
196 );
197 }
198
199 do {
200 $queryBuilder = clone $queryBuilderTemplate;
201 $queryBuilder->andWhere( $dbr->expr( 'user_id', '>', $fromUserId ) );
202 $result = $queryBuilder->fetchResultSet();
203 foreach ( $result as $row ) {
204 $fromUserId = (int)$row->user_id;
205 $user = UserIdentityValue::newRegistered( $row->user_id, $row->user_name );
206
207 if ( $fromIsDefault ) {
208 // $user has the default value for $option; skip if it doesn't match
209 // NOTE: This is intentionally a loose comparison. $from is always a string
210 // (coming from the command line), but the default value might be of a
211 // different type.
212 $oldOptionMatchingDefault = null;
213 $oldOptionIsDefault = true;
214 foreach ( $from as $oldOption ) {
215 $oldOptionIsDefault = $oldOption == $userOptionsManager->getDefaultOption( $option, $user );
216 if ( $oldOptionIsDefault ) {
217 $oldOptionMatchingDefault = $oldOption;
218 break;
219 }
220 }
221 if ( !$oldOptionIsDefault ) {
222 $this->output(
223 "Skipping $option for $row->user_name as the default value for that user is not " .
224 "specified in --from\n"
225 );
226 continue;
227 }
228
229 $fromForThisUser = $oldOptionMatchingDefault ?? $fromAsText;
230 } else {
231 $fromForThisUser = $row->up_value;
232 }
233
234 $this->output( "$settingWord $option for $row->user_name from '$fromForThisUser' to '$to'\n" );
235 if ( !$dryRun ) {
236 $userOptionsManager->setOption( $user, $option, $to );
237 $userOptionsManager->saveOptions( $user );
238 }
239 }
240 $this->waitForReplication();
241 } while ( $result->numRows() );
242 }
243
247 private function deleteOptions() {
248 $dryRun = $this->hasOption( 'dry' );
249 $option = $this->getArg( 0 );
250 $fromUserId = (int)$this->getOption( 'fromuserid', 0 );
251 $toUserId = (int)$this->getOption( 'touserid', 0 ) ?: null;
252 $old = $this->getOption( 'old' );
253
254 if ( !$dryRun ) {
255 $forUsers = ( $fromUserId || $toUserId ) ? "some users (ID $fromUserId-$toUserId)" : 'ALL USERS';
256 $this->warn( <<<WARN
257The script is about to delete '$option' option for $forUsers from user_properties table.
258This action is IRREVERSIBLE.
259
260Abort with control-c in the next five seconds....
261WARN
262 );
263 }
264
265 $dbr = $this->getReplicaDB();
266 $dbw = $this->getPrimaryDB();
267
268 $rowsNum = 0;
269 $rowsInThisBatch = -1;
270 $minUserId = $fromUserId;
271 while ( $rowsInThisBatch != 0 ) {
272 $queryBuilder = $dbr->newSelectQueryBuilder()
273 ->select( 'up_user' )
274 ->from( 'user_properties' )
275 ->where( [ 'up_property' => $option, $dbr->expr( 'up_user', '>', $minUserId ) ] );
276 if ( $this->hasOption( 'touserid' ) ) {
277 $queryBuilder->andWhere( $dbr->expr( 'up_user', '<', $toUserId ) );
278 }
279 if ( $this->hasOption( 'old' ) ) {
280 $queryBuilder->andWhere( [ 'up_value' => $old ] );
281 }
282 // need to order by ID so we can use ID ranges for query continuation
283 $queryBuilder
284 ->orderBy( 'up_user', SelectQueryBuilder::SORT_ASC )
285 ->limit( $this->getBatchSize() );
286
287 $userIds = $queryBuilder->caller( __METHOD__ )->fetchFieldValues();
288 if ( $userIds === [] ) {
289 // no rows left
290 break;
291 }
292
293 if ( !$dryRun ) {
294 $delete = $dbw->newDeleteQueryBuilder()
295 ->deleteFrom( 'user_properties' )
296 ->where( [ 'up_property' => $option, 'up_user' => $userIds ] );
297 if ( $this->hasOption( 'old' ) ) {
298 $delete->andWhere( [ 'up_value' => $old ] );
299 }
300 $delete->caller( __METHOD__ )->execute();
301 $rowsInThisBatch = $dbw->affectedRows();
302 } else {
303 $rowsInThisBatch = count( $userIds );
304 }
305
306 $this->waitForReplication();
307 $rowsNum += $rowsInThisBatch;
308 $minUserId = max( $userIds );
309 }
310
311 if ( !$dryRun ) {
312 $this->output( "Done! Deleted $rowsNum rows.\n" );
313 } else {
314 $this->output( "Would delete $rowsNum rows.\n" );
315 }
316 }
317
318 private function deleteDefaults() {
319 $dryRun = $this->hasOption( 'dry' );
320 $option = $this->getArg( 0 );
321 $fromUserId = (int)$this->getOption( 'fromuserid', 0 );
322 $toUserId = (int)$this->getOption( 'touserid', 0 ) ?: null;
323
324 if ( $option === null ) {
325 $this->fatalError( "Option name is required" );
326 }
327
328 if ( $dryRun ) {
329 $this->fatalError( "--delete-defaults does not support a dry run." );
330 }
331
332 $this->warn( <<<WARN
333This script is about to delete all rows in user_properties that match the current
334defaults for the user (including conditional defaults).
335This action is IRREVERSIBLE.
336
337Abort with control-c in the next five seconds....
338WARN
339 );
340
341 $dbr = $this->getDB( DB_REPLICA );
342
343 $queryBuilderTemplate = $dbr->newSelectQueryBuilder()
344 ->select( [ 'user_id', 'user_name', 'up_value' ] )
345 ->from( 'user_properties' )
346 ->join( 'user', null, [ 'up_user = user_id' ] )
347 ->where( [ 'up_property' => $option ] )
348 ->limit( $this->getBatchSize() )
349 ->caller( __METHOD__ );
350
351 if ( $toUserId !== null ) {
352 $queryBuilderTemplate->andWhere( $dbr->expr( 'up_user', '<=', $toUserId ) );
353 }
354
355 $userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
356 do {
357 $queryBuilder = clone $queryBuilderTemplate;
358 $queryBuilder->andWhere( $dbr->expr( 'up_user', '>', $fromUserId ) );
359 $result = $queryBuilder->fetchResultSet();
360 foreach ( $result as $row ) {
361 $fromUserId = (int)$row->user_id;
362
363 // NOTE: If up_value equals to the default, this will drop the row. Otherwise, it
364 // is going to be a no-op.
365 $user = UserIdentityValue::newRegistered( $row->user_id, $row->user_name );
366 $userOptionsManager->setOption( $user, $option, $row->up_value );
367 $userOptionsManager->saveOptions( $user );
368 }
369 $this->waitForReplication();
370 } while ( $result->numRows() );
371
372 $this->output( "Done!\n" );
373 }
374
378 private function warn( string $message ) {
379 if ( $this->hasOption( 'nowarn' ) ) {
380 return;
381 }
382
383 $this->output( $message );
384 $this->countDown( 5 );
385 }
386}
387
388// @codeCoverageIgnoreStart
389$maintClass = UserOptionsMaintenance::class;
390require_once RUN_MAINTENANCE_IF_MAIN;
391// @codeCoverageIgnoreEnd
const DB_REPLICA
Definition defines.php:26
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
addArg( $arg, $description, $required=true, $multi=false)
Add some args that are needed.
getArg( $argId=0, $default=null)
Get an argument.
getBatchSize()
Returns batch size.
output( $out, $channel=null)
Throw some output to the user.
fatalError( $msg, $exitCode=1)
Output a message and terminate the current script.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
getDB( $db, $groups=[], $dbDomain=false)
Returns a database to be used by current maintenance script.
waitForReplication()
Wait for replica DB servers to catch up.
hasOption( $name)
Checks to see if a particular option was set.
getOption( $name, $default=null)
Get an option, or return the default.
getReplicaDB(string|false $virtualDomain=false)
hasArg( $argId=0)
Does a given argument exist?
getServiceContainer()
Returns the main service container.
maybeHelp( $force=false)
Maybe show the help.
countDown( $seconds)
Count down from $seconds to zero on the terminal, with a one-second pause between showing each number...
getPrimaryDB(string|false $virtualDomain=false)
addDescription( $text)
Set the description text.
getDefaultOptions(?UserIdentity $userIdentity=null)
Combine the language default options with any site-specific and user-specific defaults and add the de...
getOption(UserIdentity $user, string $oname, $defaultOverride=null, bool $ignoreHidden=false, int $queryFlags=IDBAccessObject::READ_NORMAL)
Get the user's current setting for a given option.
Value object representing a user's identity.
User class for the MediaWiki software.
Definition User.php:130
execute()
Do the actual work.
__construct()
Default constructor.
Build SELECT queries with a fluent interface.
$maintClass