Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
108 / 108
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
FindMissingActors
100.00% covered (success)
100.00%
108 / 108
100.00% covered (success)
100.00%
7 / 7
23
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getTables
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getTableInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNewActorId
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 execute
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 findBadActors
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 overwriteActorIDs
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @ingroup Maintenance
6 */
7
8use MediaWiki\Exception\CannotCreateActorException;
9use MediaWiki\Maintenance\Maintenance;
10use MediaWiki\User\ActorNormalization;
11use MediaWiki\User\UserFactory;
12use MediaWiki\User\UserNameUtils;
13
14// @codeCoverageIgnoreStart
15require_once __DIR__ . '/Maintenance.php';
16// @codeCoverageIgnoreEnd
17
18/**
19 * Maintenance script for finding and replacing invalid actor IDs, see T261325 and T307738.
20 *
21 * @ingroup Maintenance
22 */
23class FindMissingActors extends Maintenance {
24
25    private UserFactory $userFactory;
26    private UserNameUtils $userNameUtils;
27    private ActorNormalization $actorNormalization;
28
29    public function __construct() {
30        parent::__construct();
31
32        $this->addDescription( 'Find and fix invalid actor IDs.' );
33        $this->addOption( 'field', 'The name of a database field to process',
34            true, true );
35        $this->addOption( 'type', 'Which type of invalid actors to find or fix, '
36            . 'missing or broken (with empty actor_name which can\'t be associated '
37            . 'with an existing user).',
38            false, true );
39        $this->addOption( 'skip', 'A comma-separated list of actor IDs to skip.',
40            false, true );
41        $this->addOption( 'overwrite-with', 'Replace invalid actors with this user. '
42            . 'Typically, this would be "Unknown user", but it could be any reserved '
43            . 'system user (per $wgReservedUsernames) or locally registered user. '
44            . 'If not given, invalid actors will only be listed, not fixed. '
45            . 'You will be prompted for confirmation before data is written. ',
46            false, true );
47
48        $this->setBatchSize( 1000 );
49    }
50
51    /**
52     * @return array
53     */
54    private function getTables() {
55        return [
56            'ar_actor' => [ 'archive', 'ar_actor', 'ar_id' ],
57            'img_actor' => [ 'image', 'img_actor', 'img_name' ],
58            'oi_actor' => [ 'oldimage', 'oi_actor', 'oi_archive_name' ], // no index on oi_archive_name!
59            'fa_actor' => [ 'filearchive', 'fa_actor', 'fa_id' ],
60            'fr_actor' => [ 'filerevision', 'fr_actor', 'fr_id' ],
61            'rc_actor' => [ 'recentchanges', 'rc_actor', 'rc_id' ],
62            'log_actor' => [ 'logging', 'log_actor', 'log_id' ],
63            'rev_actor' => [ 'revision', 'rev_actor', 'rev_id' ],
64            'bl_by_actor' => [ 'block', 'bl_by_actor', 'bl_id' ], // no index on bl_by_actor!
65        ];
66    }
67
68    /**
69     * @param string $field
70     * @return array|null
71     */
72    private function getTableInfo( $field ) {
73        $tables = $this->getTables();
74        return $tables[$field] ?? null;
75    }
76
77    /**
78     * Returns the actor ID of the user specified with the --overwrite-with option,
79     * or null if --overwrite-with is not set.
80     *
81     * Existing users and reserved system users are supported.
82     * If the user does not have an actor ID yet, one will be assigned.
83     *
84     * @return int|null
85     */
86    private function getNewActorId() {
87        $name = $this->getOption( 'overwrite-with' );
88
89        if ( $name === null ) {
90            return null;
91        }
92
93        $user = $this->userFactory->newFromName( $name );
94
95        if ( !$user ) {
96            $this->fatalError( "Not a valid user name: '$name'" );
97        }
98
99        if ( $user->isRegistered() ) {
100            $this->output( "Using existing user: '$user'\n" );
101        } elseif ( !$this->userNameUtils->isUsable( $user->getName() ) ) {
102            $this->output( "Using system user: '{$user->getName()}'\n" );
103        } else {
104            $this->fatalError( "Unknown user: '{$user->getName()}'" );
105        }
106
107        $dbw = $this->getPrimaryDB();
108
109        try {
110            $actorId = $this->actorNormalization->acquireActorId( $user, $dbw );
111        } catch ( CannotCreateActorException ) {
112            $this->fatalError( "Failed to acquire an actor ID for user '$user'" );
113        }
114
115        $this->output( "Replacement actor ID is $actorId.\n" );
116        return $actorId;
117    }
118
119    public function execute() {
120        $services = $this->getServiceContainer();
121        $this->userFactory = $services->getUserFactory();
122        $this->userNameUtils = $services->getUserNameUtils();
123        $this->actorNormalization = $services->getActorNormalization();
124
125        $field = $this->getOption( 'field' );
126        if ( !$this->getTableInfo( $field ) ) {
127            $this->fatalError( "Unknown field: $field.\n" );
128        }
129
130        $type = $this->getOption( 'type', 'missing' );
131        if ( $type !== 'missing' && $type !== 'broken' ) {
132            $this->fatalError( "Unknown type: $type.\n" );
133        }
134
135        $skip = $this->parseIntList( $this->getOption( 'skip', '' ) );
136        $overwrite = $this->getNewActorId();
137
138        $bad = $this->findBadActors( $field, $type, $skip );
139
140        if ( $bad && $overwrite ) {
141            $this->output( "\n" );
142            $this->output( "Do you want to OVERWRITE the listed actor IDs?\n" );
143            $this->output( "Information about the invalid IDs will be lost!\n" );
144            $this->output( "\n" );
145            $confirm = static::readconsole( 'Type "yes" to continue: ' );
146
147            if ( $confirm === 'yes' ) {
148                $this->overwriteActorIDs( $field, array_keys( $bad ), $overwrite );
149            } else {
150                $this->fatalError( 'Aborted.' );
151            }
152        }
153
154        $this->output( "Done.\n" );
155    }
156
157    /**
158     * Find rows that have bad actor IDs.
159     *
160     * @param string $field the database field in which to detect bad actor IDs.
161     * @param string $type type of bad actors, missing or broken.
162     * @param int[] $skip bad actor IDs not to replace.
163     *
164     * @return array a list of row IDs, identifying rows in which the actor ID needs to be replaced.
165     */
166    private function findBadActors( $field, $type, $skip ) {
167        [ $table, $actorField, $idField ] = $this->getTableInfo( $field );
168        $this->output( "Finding invalid actor IDs in $table.$actorField...\n" );
169
170        $dbr = $this->getServiceContainer()->getDBLoadBalancer()->getConnection( DB_REPLICA, 'vslow' );
171
172        /*
173        We are building an SQL query like this one here, performing a left join
174        to detect rows in $table that lack a matching row in the actor table.
175
176        In this example, $field is 'log_actor', so $table is 'logging',
177        $actorField is 'log_actor', and $idField is 'log_id'.
178        Further, $skip is [ 1, 2, 3, 4 ] and the batch size is 1000.
179
180        SELECT log_id
181        FROM logging
182        LEFT JOIN actor ON log_actor = actor_id
183        WHERE actor_id IS NULL
184        AND log_actor NOT IN (1, 2, 3, 4)
185        LIMIT 1000;
186        */
187
188        $queryBuilder = $dbr->newSelectQueryBuilder()
189            ->select( [ $actorField, $idField ] )
190            ->from( $table )
191            ->leftJoin( 'actor', null, [ "$actorField = actor_id" ] )
192            ->where( $type == 'missing' ? [ 'actor_id' => null ] : [ 'actor_name' => '' ] )
193            ->limit( $this->getBatchSize() );
194
195        if ( $skip ) {
196            $queryBuilder->andWhere( $dbr->expr( $actorField, '!=', $skip ) );
197        }
198
199        $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
200        $count = $res->numRows();
201
202        $bad = [];
203
204        if ( $count ) {
205            $this->output( "\t\tID\tACTOR\n" );
206        }
207
208        foreach ( $res as $row ) {
209            $id = $row->$idField;
210            $actor = (int)( $row->$actorField );
211
212            $bad[$id] = $actor;
213            $this->output( "\t\t$id\t$actor\n" );
214        }
215
216        $this->output( "\tFound $count invalid actor IDs.\n" );
217
218        if ( $count >= $this->getBatchSize() ) {
219            $this->output( "\tBatch size reached, run again after fixing the current batch.\n" );
220        }
221
222        return $bad;
223    }
224
225    /**
226     * Overwrite the actor ID in a given set of rows.
227     *
228     * @param string $field the database field in which to replace IDs.
229     * @param array $ids The row IDs of the rows in which the actor ID should be replaced
230     * @param int $overwrite The actor ID to write to the rows identified by $ids.
231     *
232     * @return int
233     */
234    private function overwriteActorIDs( $field, array $ids, int $overwrite ) {
235        [ $table, $actorField, $idField ] = $this->getTableInfo( $field );
236
237        $count = count( $ids );
238        $this->output( "OVERWRITING $count actor IDs in $table.$actorField with $overwrite...\n" );
239
240        $dbw = $this->getPrimaryDB();
241
242        $dbw->newUpdateQueryBuilder()
243            ->update( $table )
244            ->set( [ $actorField => $overwrite ] )
245            ->where( [ $idField => $ids ] )
246            ->caller( __METHOD__ )->execute();
247
248        $count = $dbw->affectedRows();
249
250        $this->waitForReplication();
251        $this->output( "\tUpdated $count rows.\n" );
252
253        return $count;
254    }
255
256}
257
258// @codeCoverageIgnoreStart
259$maintClass = FindMissingActors::class;
260require_once RUN_MAINTENANCE_IF_MAIN;
261// @codeCoverageIgnoreEnd