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