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