Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MigrateActorsAF
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 8
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getUpdateKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doDBUpdates
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 doTable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 makeNextCond
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 makeActorIdSubquery
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 addActorsForRows
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
90
 migrate
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2/**
3 * Migrate actors to the 'actor' table
4 *
5 * @file
6 * @ingroup Maintenance
7 */
8
9namespace MediaWiki\Extension\AbuseFilter\Maintenance;
10
11use LoggedUpdateMaintenance;
12use MediaWiki\MediaWikiServices;
13use stdClass;
14use Wikimedia\Rdbms\IDatabase;
15use Wikimedia\Rdbms\Platform\ISQLPlatform;
16
17/**
18 * Maintenance script that migrates actors from AbuseFilter tables to the 'actor' table.
19 *
20 * Code was copy-pasted from core's maintenance/includes/MigrateActors.php (before removal
21 * in ba3155214), except our custom ::doDBUpdates.
22 *
23 * @ingroup Maintenance
24 */
25class MigrateActorsAF extends LoggedUpdateMaintenance {
26
27    /** @var string[]|null */
28    private $tables = null;
29
30    public function __construct() {
31        parent::__construct();
32        $this->addOption( 'tables', 'List of tables to process, comma-separated', false, true );
33        $this->setBatchSize( 100 );
34        $this->addDescription( 'Migrates actors from AbuseFilter tables to the \'actor\' table' );
35        $this->requireExtension( 'Abuse Filter' );
36    }
37
38    /**
39     * @inheritDoc
40     */
41    protected function getUpdateKey() {
42        return __CLASS__;
43    }
44
45    /**
46     * @inheritDoc
47     */
48    protected function doDBUpdates() {
49        $tables = $this->getOption( 'tables' );
50        if ( $tables !== null ) {
51            $this->tables = explode( ',', $tables );
52        }
53
54        $stage = $this->getConfig()->get( 'AbuseFilterActorTableSchemaMigrationStage' );
55        if ( !( $stage & SCHEMA_COMPAT_WRITE_NEW ) ) {
56            $this->output(
57                '...cannot update while $wgAbuseFilterActorTableSchemaMigrationStage ' .
58                "lacks SCHEMA_COMPAT_WRITE_NEW\n"
59            );
60            return false;
61        }
62
63        $errors = 0;
64        $errors += $this->migrate( 'abuse_filter', 'af_id', 'af_user', 'af_user_text', 'af_actor' );
65        $errors += $this->migrate(
66            'abuse_filter_history', 'afh_id', 'afh_user', 'afh_user_text', 'afh_actor' );
67
68        return $errors === 0;
69    }
70
71    /**
72     * @param string $table
73     * @return bool
74     */
75    private function doTable( $table ) {
76        return $this->tables === null || in_array( $table, $this->tables, true );
77    }
78
79    /**
80     * Calculate a "next" condition and a display string
81     * @param IDatabase $dbw
82     * @param string[] $primaryKey Primary key of the table.
83     * @param stdClass $row Database row
84     * @return array [ string $next, string $display ]
85     */
86    private function makeNextCond( $dbw, $primaryKey, $row ) {
87        $next = '';
88        $display = [];
89        for ( $i = count( $primaryKey ) - 1; $i >= 0; $i-- ) {
90            $field = $primaryKey[$i];
91            $display[] = $field . '=' . $row->$field;
92            $value = $dbw->addQuotes( $row->$field );
93            if ( $next === '' ) {
94                $next = "$field > $value";
95            } else {
96                $next = "$field > $value OR $field = $value AND ($next)";
97            }
98        }
99        $display = implode( ' ', array_reverse( $display ) );
100        return [ $next, $display ];
101    }
102
103    /**
104     * Make the subqueries for `actor_id`
105     * @param ISQLPlatform $dbw
106     * @param string $userField User ID field name
107     * @param string $nameField User name field name
108     * @return string SQL fragment
109     */
110    private function makeActorIdSubquery( ISQLPlatform $dbw, $userField, $nameField ) {
111        $idSubquery = $dbw->buildSelectSubquery(
112            'actor',
113            'actor_id',
114            [ "$userField = actor_user" ],
115            __METHOD__
116        );
117        $nameSubquery = $dbw->buildSelectSubquery(
118            'actor',
119            'actor_id',
120            [ "$nameField = actor_name" ],
121            __METHOD__
122        );
123        return "CASE WHEN $userField = 0 OR $userField IS NULL THEN $nameSubquery ELSE $idSubquery END";
124    }
125
126    /**
127     * Add actors for anons in a set of rows
128     *
129     * @param IDatabase $dbw
130     * @param string $nameField
131     * @param stdClass[] &$rows
132     * @param array &$complainedAboutUsers
133     * @param int &$countErrors
134     * @return int Count of actors inserted
135     */
136    private function addActorsForRows(
137        IDatabase $dbw, $nameField, array &$rows, array &$complainedAboutUsers, &$countErrors
138    ) {
139        $needActors = [];
140        $countActors = 0;
141        $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
142
143        $keep = [];
144        foreach ( $rows as $index => $row ) {
145            $keep[$index] = true;
146            if ( $row->actor_id === null ) {
147                // All registered users should have an actor_id already. So
148                // if we have a usable name here, it means they didn't run
149                // maintenance/cleanupUsersWithNoId.php
150                $name = $row->$nameField;
151                if ( $userNameUtils->isUsable( $name ) ) {
152                    if ( !isset( $complainedAboutUsers[$name] ) ) {
153                        $complainedAboutUsers[$name] = true;
154                        $this->error(
155                            "User name \"$name\" is usable, cannot create an anonymous actor for it."
156                            . " Run maintenance/cleanupUsersWithNoId.php to fix this situation.\n"
157                        );
158                    }
159                    unset( $keep[$index] );
160                    $countErrors++;
161                } else {
162                    $needActors[$name] = 0;
163                }
164            }
165        }
166        $rows = array_intersect_key( $rows, $keep );
167
168        if ( $needActors ) {
169            $dbw->newInsertQueryBuilder()
170                ->insertInto( 'actor' )
171                ->ignore()
172                ->rows( array_map( static function ( $v ) {
173                    return [
174                        'actor_name' => $v,
175                    ];
176                }, array_keys( $needActors ) ) )
177                ->caller( __METHOD__ )
178                ->execute();
179            $countActors += $dbw->affectedRows();
180
181            $res = $dbw->newSelectQueryBuilder()
182                ->select( [ 'actor_id', 'actor_name' ] )
183                ->from( 'actor' )
184                ->where( [ 'actor_name' => array_map( 'strval', array_keys( $needActors ) ) ] )
185                ->caller( __METHOD__ )
186                ->fetchResultSet();
187            foreach ( $res as $row ) {
188                $needActors[$row->actor_name] = $row->actor_id;
189            }
190            foreach ( $rows as $row ) {
191                if ( $row->actor_id === null ) {
192                    $row->actor_id = $needActors[$row->$nameField];
193                }
194            }
195        }
196
197        return $countActors;
198    }
199
200    /**
201     * Migrate actors in a table.
202     *
203     * Assumes any row with the actor field non-zero have already been migrated.
204     * Blanks the name field when migrating.
205     *
206     * @param string $table Table to migrate
207     * @param string|string[] $primaryKey Primary key of the table.
208     * @param string $userField User ID field name
209     * @param string $nameField User name field name
210     * @param string $actorField Actor field name
211     * @return int Number of errors
212     */
213    private function migrate( $table, $primaryKey, $userField, $nameField, $actorField ) {
214        if ( !$this->doTable( $table ) ) {
215            $this->output( "Skipping $table, not included in --tables\n" );
216            return 0;
217        }
218
219        $dbw = $this->getDB( DB_PRIMARY );
220        if ( !$dbw->fieldExists( $table, $userField, __METHOD__ ) ) {
221            $this->output( "No need to migrate $table.$userField, field does not exist\n" );
222            return 0;
223        }
224
225        $complainedAboutUsers = [];
226
227        $primaryKey = (array)$primaryKey;
228        $pkFilter = array_fill_keys( $primaryKey, true );
229        $this->output(
230            "Beginning migration of $table.$userField and $table.$nameField to $table.$actorField\n"
231        );
232        $this->waitForReplication();
233
234        $actorIdSubquery = $this->makeActorIdSubquery( $dbw, $userField, $nameField );
235        $next = '1=1';
236        $countUpdated = 0;
237        $countActors = 0;
238        $countErrors = 0;
239        while ( true ) {
240            // Fetch the rows needing update
241            $res = $dbw->newSelectQueryBuilder()
242                ->select( $primaryKey )
243                ->fields( [ $userField, $nameField, 'actor_id' => $actorIdSubquery ] )
244                ->from( $table )
245                ->where( [
246                    $actorField => 0,
247                    $next,
248                ] )
249                ->orderBy( $primaryKey )
250                ->limit( $this->mBatchSize )
251                ->caller( __METHOD__ )
252                ->fetchResultSet();
253            if ( !$res->numRows() ) {
254                break;
255            }
256
257            // Insert new actors for rows that need one
258            $rows = iterator_to_array( $res );
259            $lastRow = end( $rows );
260            $countActors += $this->addActorsForRows(
261                $dbw, $nameField, $rows, $complainedAboutUsers, $countErrors
262            );
263
264            // Update the existing rows
265            foreach ( $rows as $row ) {
266                if ( !$row->actor_id ) {
267                    [ , $display ] = $this->makeNextCond( $dbw, $primaryKey, $row );
268                    $this->error(
269                        "Could not make actor for row with $display "
270                        . "$userField={$row->$userField} $nameField={$row->$nameField}\n"
271                    );
272                    $countErrors++;
273                    continue;
274                }
275                $dbw->newUpdateQueryBuilder()
276                    ->update( $table )
277                    ->set( [
278                        $actorField => $row->actor_id,
279                    ] )
280                    ->where( array_intersect_key( (array)$row, $pkFilter ) + [
281                        $actorField => 0
282                    ] )
283                    ->caller( __METHOD__ )
284                    ->execute();
285                $countUpdated += $dbw->affectedRows();
286            }
287
288            [ $next, $display ] = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
289            $this->output( "... $display\n" );
290            $this->waitForReplication();
291        }
292
293        $this->output(
294            "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
295            . "$countErrors error(s)\n"
296        );
297        return $countErrors;
298    }
299
300}
301
302$maintClass = MigrateActorsAF::class;
303require_once RUN_MAINTENANCE_IF_MAIN;