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