Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.31% covered (danger)
22.31%
85 / 381
23.08% covered (danger)
23.08%
3 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
MergeUser
22.31% covered (danger)
22.31%
85 / 381
23.08% covered (danger)
23.08%
3 / 13
2108.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 merge
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 mergeEditcount
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 mergeBlocks
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
56
 chooseBlock
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 stageNeedsUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 stageNeedsActor
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 mergeDatabaseTables
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 1
420
 deduplicateWatchlistEntries
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
42
 movePages
91.38% covered (success)
91.38%
53 / 58
0.00% covered (danger)
0.00%
0 / 1
10.06
 deletePage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 deleteUser
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3use MediaWiki\Block\DatabaseBlock;
4use MediaWiki\Extension\UserMerge\Hooks\HookRunner;
5use MediaWiki\MediaWikiServices;
6use Wikimedia\Rdbms\IDatabase;
7
8/**
9 * Contains the actual database backend logic for merging users
10 */
11class MergeUser {
12    /**
13     * @var User
14     */
15    private $oldUser;
16    /**
17     * @var User
18     */
19    private $newUser;
20
21    /**
22     * @var IUserMergeLogger
23     */
24    private $logger;
25
26    /** @var int */
27    private $flags;
28
29    // allow begin/commit; useful for jobs or CLI mode
30    public const USE_MULTI_COMMIT = 1;
31
32    /**
33     * @param User $oldUser
34     * @param User $newUser
35     * @param IUserMergeLogger $logger
36     * @param int $flags Bitfield (Supports MergeUser::USE_*)
37     */
38    public function __construct(
39        User $oldUser,
40        User $newUser,
41        IUserMergeLogger $logger,
42        $flags = 0
43    ) {
44        $this->newUser = $newUser;
45        $this->oldUser = $oldUser;
46        $this->logger = $logger;
47        $this->flags = $flags;
48    }
49
50    /**
51     * @param User $performer
52     * @param string $fnameTrxOwner
53     */
54    public function merge( User $performer, $fnameTrxOwner = __METHOD__ ) {
55        $this->mergeEditcount();
56        $this->mergeDatabaseTables( $fnameTrxOwner );
57        $this->logger->addMergeEntry( $performer, $this->oldUser, $this->newUser );
58    }
59
60    /**
61     * @param User $performer
62     * @param callable $msg something that returns a Message object
63     *
64     * @return array Array of failed page moves, see MergeUser::movePages
65     */
66    public function delete( User $performer, $msg ) {
67        $failed = $this->movePages( $performer, $msg );
68        $this->deleteUser();
69        $this->logger->addDeleteEntry( $performer, $this->oldUser );
70
71        return $failed;
72    }
73
74    /**
75     * Adds edit count of both users
76     */
77    private function mergeEditcount() {
78        $dbw = MediaWikiServices::getInstance()
79            ->getConnectionProvider()
80            ->getPrimaryDatabase();
81        $dbw->startAtomic( __METHOD__ );
82
83        $totalEdits = $dbw->selectField(
84            'user',
85            'SUM(user_editcount)',
86            [ 'user_id' => [ $this->newUser->getId(), $this->oldUser->getId() ] ],
87            __METHOD__
88        );
89
90        $totalEdits = (int)$totalEdits;
91
92        # don't run queries if neither user has any edits
93        if ( $totalEdits > 0 ) {
94            # update new user with total edits
95            $dbw->newUpdateQueryBuilder()
96                ->update( 'user' )
97                ->set( [ 'user_editcount' => $totalEdits ] )
98                ->where( [ 'user_id' => $this->newUser->getId() ] )
99                ->caller( __METHOD__ )
100                ->execute();
101
102            # clear old user's edits
103            $dbw->newUpdateQueryBuilder()
104                ->update( 'user' )
105                ->set( [ 'user_editcount' => 0 ] )
106                ->where( [ 'user_id' => $this->oldUser->getId() ] )
107                ->caller( __METHOD__ )
108                ->execute();
109        }
110
111        $dbw->endAtomic( __METHOD__ );
112    }
113
114    /**
115     * @param IDatabase $dbw
116     * @return void
117     */
118    private function mergeBlocks( IDatabase $dbw ) {
119        $dbw->startAtomic( __METHOD__ );
120
121        // Pull blocks directly from primary
122        $qi = DatabaseBlock::getQueryInfo();
123        $rows = $dbw->select(
124            $qi['tables'],
125            array_merge( $qi['fields'], [ 'ipb_user' ] ),
126            [
127                'ipb_user' => [ $this->oldUser->getId(), $this->newUser->getId() ],
128            ],
129            __METHOD__,
130            [],
131            $qi['joins']
132        );
133
134        $newBlock = null;
135        $oldBlock = null;
136        foreach ( $rows as $row ) {
137            if ( (int)$row->ipb_user === $this->oldUser->getId() ) {
138                $oldBlock = $row;
139            } elseif ( (int)$row->ipb_user === $this->newUser->getId() ) {
140                $newBlock = $row;
141            }
142        }
143
144        if ( !$oldBlock ) {
145            // No one is blocked or
146            // Only the new user is blocked, so nothing to do.
147            $dbw->endAtomic( __METHOD__ );
148            return;
149        }
150        if ( !$newBlock ) {
151            // Just move the old block to the new username
152            $dbw->newUpdateQueryBuilder()
153                ->update( 'ipblocks' )
154                ->set( [ 'ipb_user' => $this->newUser->getId() ] )
155                ->where( [ 'ipb_id' => $oldBlock->ipb_id ] )
156                ->caller( __METHOD__ )
157                ->execute();
158            $dbw->endAtomic( __METHOD__ );
159            return;
160        }
161
162        // Okay, let's pick the "strongest" block, and re-apply it to
163        // the new user.
164        $oldBlockObj = DatabaseBlock::newFromRow( $oldBlock );
165        $newBlockObj = DatabaseBlock::newFromRow( $newBlock );
166
167        $winner = $this->chooseBlock( $oldBlockObj, $newBlockObj );
168        if ( $winner->getId() === $newBlockObj->getId() ) {
169            $oldBlockObj->delete();
170        } else {
171            // Old user block won
172            // Delete current new block
173            $newBlockObj->delete();
174            $dbw->newUpdateQueryBuilder()
175                ->update( 'ipblocks' )
176                ->set( [ 'ipb_user' => $this->newUser->getId() ] )
177                ->where( [ 'ipb_id' => $winner->getId() ] )
178                ->caller( __METHOD__ )
179                ->execute();
180        }
181
182        $dbw->endAtomic( __METHOD__ );
183    }
184
185    /**
186     * @param DatabaseBlock $b1
187     * @param DatabaseBlock $b2
188     * @return DatabaseBlock
189     */
190    private function chooseBlock( DatabaseBlock $b1, DatabaseBlock $b2 ) {
191        // First, see if one is longer than the other.
192        if ( $b1->getExpiry() !== $b2->getExpiry() ) {
193            // This works for infinite blocks because:
194            // "infinity" > "20141024234513"
195            if ( $b1->getExpiry() > $b2->getExpiry() ) {
196                return $b1;
197            } else {
198                return $b2;
199            }
200        }
201
202        // Next check what they block, in order
203        $blockProps = [];
204        foreach ( [ $b1, $b2 ] as $block ) {
205            $blockProps[] = [
206                'block' => $block,
207                'createaccount' => $block->isCreateAccountBlocked(),
208                'sendemail' => $block->isEmailBlocked(),
209                'editownusertalk' => !$block->isUsertalkEditAllowed(),
210            ];
211        }
212        foreach ( [ 'createaccount', 'sendemail', 'editownusertalk' ] as $action ) {
213            if ( $blockProps[0][$action] xor $blockProps[1][$action] ) {
214                if ( $blockProps[0][$action] ) {
215                    return $blockProps[0]['block'];
216                } else {
217                    return $blockProps[1]['block'];
218                }
219            }
220        }
221
222        // Give up, return the second one.
223        return $b2;
224    }
225
226    /**
227     * @param int $stage
228     *
229     * @return bool
230     */
231    private function stageNeedsUser( $stage ) {
232        if ( !defined( 'MIGRATION_NEW' ) ) {
233            return true;
234        }
235
236        if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) {
237            return (bool)( (int)$stage & SCHEMA_COMPAT_WRITE_OLD );
238        } else {
239            return $stage < MIGRATION_NEW;
240        }
241    }
242
243    /**
244     * @param int $stage
245     *
246     * @return bool
247     */
248    private function stageNeedsActor( $stage ) {
249        if ( !defined( 'MIGRATION_NEW' ) ) {
250            return false;
251        }
252
253        if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) {
254            return (bool)( $stage & SCHEMA_COMPAT_WRITE_NEW );
255        } else {
256            return $stage > MIGRATION_OLD;
257        }
258    }
259
260    /**
261     * Function to merge database references from one user to another user
262     *
263     * Merges database references from one user ID or username to another user ID or username
264     * to preserve referential integrity.
265     *
266     * @param string $fnameTrxOwner
267     */
268    private function mergeDatabaseTables( $fnameTrxOwner ) {
269        // Fields to update with the format:
270        // [
271        // tableName, idField, textField,
272        // 'batchKey' => unique field, 'options' => array(), 'db' => IDatabase
273        // 'actorId' => actor ID field,
274        // 'actorStage' => actor schema migration stage
275        // ];
276        // textField, batchKey, db, and options are optional
277        $updateFields = [
278            [ 'archive', 'batchKey' => 'ar_id', 'actorId' => 'ar_actor',
279                'actorStage' => SCHEMA_COMPAT_NEW ],
280            [ 'revision', 'batchKey' => 'rev_id', 'actorId' => 'rev_actor',
281                'actorStage' => SCHEMA_COMPAT_NEW ],
282            [ 'filearchive', 'batchKey' => 'fa_id', 'actorId' => 'fa_actor',
283                'actorStage' => SCHEMA_COMPAT_NEW ],
284            [ 'image', 'batchKey' => 'img_name', 'actorId' => 'img_actor',
285                'actorStage' => SCHEMA_COMPAT_NEW ],
286            [ 'oldimage', 'batchKey' => 'oi_archive_name', 'actorId' => 'oi_actor',
287                'actorStage' => SCHEMA_COMPAT_NEW ],
288            [ 'recentchanges', 'batchKey' => 'rc_id', 'actorId' => 'rc_actor',
289                'actorStage' => SCHEMA_COMPAT_NEW ],
290            [ 'logging', 'batchKey' => 'log_id', 'actorId' => 'log_actor',
291                'actorStage' => SCHEMA_COMPAT_NEW ],
292            [ 'ipblocks', 'batchKey' => 'ipb_id', 'actorId' => 'ipb_by_actor',
293                'actorStage' => SCHEMA_COMPAT_NEW ],
294            [ 'watchlist', 'wl_user', 'batchKey' => 'wl_title' ],
295            [ 'user_groups', 'ug_user', 'options' => [ 'IGNORE' ] ],
296            [ 'user_properties', 'up_user', 'options' => [ 'IGNORE' ] ],
297            [ 'user_former_groups', 'ufg_user', 'options' => [ 'IGNORE' ] ],
298            [ 'revision_actor_temp', 'batchKey' => 'revactor_rev', 'actorId' => 'revactor_actor',
299                'actorStage' => SCHEMA_COMPAT_TEMP ],
300        ];
301
302        $services = MediaWikiServices::getInstance();
303        $hookRunner = new HookRunner( $services->getHookContainer() );
304        $hookRunner->onUserMergeAccountFields( $updateFields );
305
306        $lbFactory = $services->getDBLoadBalancerFactory();
307        $dbw = $lbFactory->getPrimaryDatabase();
308        $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
309
310        $this->deduplicateWatchlistEntries( $dbw );
311        $this->mergeBlocks( $dbw );
312
313        if ( $this->flags & self::USE_MULTI_COMMIT ) {
314            // Flush prior writes; this actives the non-transaction path in the loop below.
315            $lbFactory->commitPrimaryChanges( $fnameTrxOwner );
316        }
317
318        foreach ( $updateFields as $fieldInfo ) {
319            if ( !isset( $fieldInfo[1] ) ) {
320                // Actors only
321                continue;
322            }
323
324            $options = $fieldInfo['options'] ?? [];
325            unset( $fieldInfo['options'] );
326            $db = $fieldInfo['db'] ?? $dbw;
327            unset( $fieldInfo['db'] );
328            $tableName = array_shift( $fieldInfo );
329            $idField = array_shift( $fieldInfo );
330            $keyField = $fieldInfo['batchKey'] ?? null;
331            unset( $fieldInfo['batchKey'] );
332
333            if ( isset( $fieldInfo['actorId'] ) && isset( $fieldInfo['actorStage'] ) &&
334                !$this->stageNeedsUser( $fieldInfo['actorStage'] )
335            ) {
336                continue;
337            }
338            unset( $fieldInfo['actorId'], $fieldInfo['actorStage'] );
339
340            if ( $db->trxLevel() || $keyField === null ) {
341                // Can't batch/wait when in a transaction or when no batch key is given
342                $db->newUpdateQueryBuilder()
343                    ->update( $tableName )
344                    ->set( [ $idField => $this->newUser->getId() ]
345                        + array_fill_keys( $fieldInfo, $this->newUser->getName() ) )
346                    ->where( [ $idField => $this->oldUser->getId() ] )
347                    ->options( $options )
348                    ->caller( __METHOD__ )
349                    ->execute();
350            } else {
351                $limit = 200;
352                do {
353                    $checkSince = microtime( true );
354                    // Note that UPDATE with ORDER BY + LIMIT is not well supported.
355                    // Grab a batch of values on a mostly unique column for this user ID.
356                    $res = $db->select(
357                        $tableName,
358                        [ $keyField ],
359                        [ $idField => $this->oldUser->getId() ],
360                        __METHOD__,
361                        [ 'LIMIT' => $limit ]
362                    );
363                    $keyValues = [];
364                    foreach ( $res as $row ) {
365                        $keyValues[] = $row->$keyField;
366                    }
367                    // Update only those rows with the given column values
368                    if ( count( $keyValues ) ) {
369                        $db->newUpdateQueryBuilder()
370                            ->update( $tableName )
371                            ->set( [ $idField => $this->newUser->getId() ]
372                                + array_fill_keys( $fieldInfo, $this->newUser->getName() ) )
373                            ->where( [ $idField => $this->oldUser->getId(), $keyField => $keyValues ] )
374                            ->options( $options )
375                            ->caller( __METHOD__ )
376                            ->execute();
377                    }
378                    // Wait for replication to catch up
379                    $opts = [ 'ifWritesSince' => $checkSince ];
380                    $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts );
381                } while ( count( $keyValues ) >= $limit );
382            }
383        }
384
385        if ( $this->oldUser->getActorId() ) {
386            $oldActorId = $this->oldUser->getActorId();
387            $newActorId = MediaWikiServices::getInstance()
388                ->getActorNormalization()
389                ->acquireActorId( $this->newUser, $dbw );
390
391            foreach ( $updateFields as $fieldInfo ) {
392                if ( empty( $fieldInfo['actorId'] ) || empty( $fieldInfo['actorStage'] ) ||
393                    !$this->stageNeedsActor( $fieldInfo['actorStage'] )
394                ) {
395                    continue;
396                }
397
398                $options = $fieldInfo['options'] ?? [];
399                $db = $fieldInfo['db'] ?? $dbw;
400                $tableName = array_shift( $fieldInfo );
401                $idField = $fieldInfo['actorId'];
402                $keyField = $fieldInfo['batchKey'] ?? null;
403
404                if ( $db->trxLevel() || $keyField === null ) {
405                    // Can't batch/wait when in a transaction or when no batch key is given
406                    $db->newUpdateQueryBuilder()
407                        ->update( $tableName )
408                        ->set( [ $idField => $newActorId ] )
409                        ->where( [ $idField => $oldActorId ] )
410                        ->options( $options )
411                        ->caller( __METHOD__ )
412                        ->execute();
413                } else {
414                    $limit = 200;
415                    do {
416                        $checkSince = microtime( true );
417                        // Note that UPDATE with ORDER BY + LIMIT is not well supported.
418                        // Grab a batch of values on a mostly unique column for this user ID.
419                        $res = $db->select(
420                            $tableName,
421                            [ $keyField ],
422                            [ $idField => $oldActorId ],
423                            __METHOD__,
424                            [ 'LIMIT' => $limit ]
425                        );
426                        $keyValues = [];
427                        foreach ( $res as $row ) {
428                            $keyValues[] = $row->$keyField;
429                        }
430                        // Update only those rows with the given column values
431                        if ( count( $keyValues ) ) {
432                            $db->newUpdateQueryBuilder()
433                                ->update( $tableName )
434                                ->set( [ $idField => $newActorId ] )
435                                ->where( [ $idField => $oldActorId, $keyField => $keyValues ] )
436                                ->options( $options )
437                                ->caller( __METHOD__ )
438                                ->execute();
439                        }
440                        // Wait for replication to catch up
441                        $opts = [ 'ifWritesSince' => $checkSince ];
442                        $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts );
443                    } while ( count( $keyValues ) >= $limit );
444                }
445            }
446        }
447
448        $dbw->newDeleteQueryBuilder()
449            ->deleteFrom( 'user_newtalk' )
450            ->where( [ 'user_id' => $this->oldUser->getId() ] )
451            ->caller( __METHOD__ )
452            ->execute();
453        $this->oldUser->clearInstanceCache();
454        $this->newUser->clearInstanceCache();
455
456        $hookRunner->onMergeAccountFromTo( $this->oldUser, $this->newUser );
457    }
458
459    /**
460     * Deduplicate watchlist entries
461     * which old (merge-from) and new (merge-to) users are watching
462     *
463     * @param IDatabase $dbw
464     */
465    private function deduplicateWatchlistEntries( $dbw ) {
466        $dbw->startAtomic( __METHOD__ );
467
468        // Get all titles both watched by the old and new user accounts.
469        // Avoid using self-joins as this fails on temporary tables (e.g. unit tests).
470        // See https://bugs.mysql.com/bug.php?id=10327.
471        $titlesToDelete = [];
472        $res = $dbw->select(
473            'watchlist',
474            [ 'wl_namespace', 'wl_title' ],
475            [ 'wl_user' => $this->oldUser->getId() ],
476            __METHOD__,
477            [ 'FOR UPDATE' ]
478        );
479        foreach ( $res as $row ) {
480            $titlesToDelete[$row->wl_namespace . "|" . $row->wl_title] = false;
481        }
482        $res = $dbw->select(
483            'watchlist',
484            [ 'wl_namespace', 'wl_title' ],
485            [ 'wl_user' => $this->newUser->getId() ],
486            __METHOD__,
487            [ 'FOR UPDATE' ]
488        );
489        foreach ( $res as $row ) {
490            $key = $row->wl_namespace . "|" . $row->wl_title;
491            if ( isset( $titlesToDelete[$key] ) ) {
492                $titlesToDelete[$key] = true;
493            }
494        }
495        $titlesToDelete = array_filter( $titlesToDelete );
496
497        $conds = [];
498        foreach ( array_keys( $titlesToDelete ) as $tuple ) {
499            [ $ns, $dbKey ] = explode( "|", $tuple, 2 );
500            $conds[] = $dbw->makeList(
501                [
502                    'wl_user' => $this->oldUser->getId(),
503                    'wl_namespace' => $ns,
504                    'wl_title' => $dbKey
505                ],
506                LIST_AND
507            );
508        }
509
510        if ( count( $conds ) ) {
511            # Perform a multi-row delete
512            $dbw->newDeleteQueryBuilder()
513                ->deleteFrom( 'watchlist' )
514                ->where( $dbw->makeList( $conds, LIST_OR ) )
515                ->caller( __METHOD__ )
516                ->execute();
517        }
518
519        $dbw->endAtomic( __METHOD__ );
520    }
521
522    /**
523     * Function to merge user pages
524     *
525     * Deletes all pages when merging to Anon
526     * Moves user page when the target user page does not exist or is empty
527     * Deletes redirect if nothing links to old page
528     * Deletes the old user page when the target user page exists
529     *
530     * @todo This code is a duplicate of Renameuser and GlobalRename
531     *
532     * @param User $performer
533     * @param callable $msg Function that returns a Message object
534     * @return array Array of old name (string) => new name (Title) where the move failed
535     */
536    private function movePages( User $performer, $msg ) {
537        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
538
539        $oldusername = trim( str_replace( '_', ' ', $this->oldUser->getName() ) );
540        $oldusername = Title::makeTitle( NS_USER, $oldusername );
541        $newusername = Title::makeTitleSafe( NS_USER, $contLang->ucfirst( $this->newUser->getName() ) );
542
543        # select all user pages and sub-pages
544        $dbr = MediaWikiServices::getInstance()
545            ->getConnectionProvider()
546            ->getReplicaDatabase();
547        $pages = $dbr->select(
548            'page',
549            [ 'page_namespace', 'page_title' ],
550            [
551                'page_namespace' => [ NS_USER, NS_USER_TALK ],
552                'page_title' . $dbr->buildLike( $oldusername->getDBkey() . '/', $dbr->anyString() )
553                    . ' OR page_title = ' . $dbr->addQuotes( $oldusername->getDBkey() ),
554            ],
555            __METHOD__
556        );
557
558        $message = static function () use ( $msg ) {
559            return call_user_func_array( $msg, func_get_args() );
560        };
561
562        $failedMoves = [];
563        foreach ( $pages as $row ) {
564            $oldPage = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
565            $newPage = Title::makeTitleSafe( $row->page_namespace,
566                preg_replace( '!^[^/]+!', $newusername->getDBkey(), $row->page_title ) );
567
568            if ( $this->newUser->getName() === 'Anonymous' ) {
569                # delete ALL old pages
570                if ( $oldPage->exists() ) {
571                    $this->deletePage( $message, $performer, $oldPage );
572                }
573            } elseif ( $newPage->exists()
574                && !MediaWikiServices::getInstance()
575                    ->getMovePageFactory()
576                    ->newMovePage( $oldPage, $newPage )
577                    ->isValidMove()
578                    ->isOk()
579                && $newPage->getLength() > 0
580            ) {
581                # delete old pages that can't be moved
582                $this->deletePage( $message, $performer, $oldPage );
583            } else {
584                # move content to new page
585                # delete target page if it exists and is blank
586                if ( $newPage->exists() ) {
587                    $this->deletePage( $message, $performer, $newPage );
588                }
589
590                # move to target location
591                $status = MediaWikiServices::getInstance()
592                    ->getMovePageFactory()
593                    ->newMovePage( $oldPage, $newPage )
594                    ->move(
595                        $performer,
596                        $message(
597                            'usermerge-move-log',
598                            $oldusername->getText(),
599                            $newusername->getText() )->inContentLanguage()->text()
600                    );
601                if ( !$status->isOk() ) {
602                    $failedMoves[$oldPage->getPrefixedText()] = $newPage;
603                }
604
605                # check if any pages link here
606                $res = $dbr->selectField( 'pagelinks',
607                    'pl_title',
608                    [ 'pl_title' => $this->oldUser->getName() ],
609                    __METHOD__
610                );
611                if ( $res === false ) {
612                    # nothing links here, so delete unmoved page/redirect
613                    $this->deletePage( $message, $performer, $oldPage );
614                }
615            }
616        }
617
618        return $failedMoves;
619    }
620
621    /**
622     * Helper to delete pages
623     *
624     * @param callable $msg
625     * @param User $user
626     * @param Title $title
627     */
628    private function deletePage( $msg, User $user, Title $title ) {
629        $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
630        $reason = $msg( 'usermerge-autopagedelete' )->inContentLanguage()->text();
631        $error = '';
632        $wikipage->doDeleteArticleReal(
633            $reason,
634            $user,
635            false,
636            // Unused
637            null,
638            $error,
639            // Unused
640            null,
641            [],
642            'delete',
643            true
644        );
645    }
646
647    /**
648     * Function to delete users following a successful mergeUser call.
649     *
650     * Removes rows from the user, user_groups, user_properties
651     * and user_former_groups tables.
652     */
653    private function deleteUser() {
654        $dbw = MediaWikiServices::getInstance()
655            ->getConnectionProvider()
656            ->getPrimaryDatabase();
657        '@phan-var \Wikimedia\Rdbms\IMaintainableDatabase $dbw';
658
659        /**
660         * Format is: table => user_id column
661         *
662         * If you want it to use a different db object:
663         * table => array( user_id colum, 'db' => IDatabase );
664         */
665        $tablesToDelete = [
666            'user_groups' => 'ug_user',
667            'user_properties' => 'up_user',
668            'user_former_groups' => 'ufg_user',
669        ];
670
671        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
672        $hookRunner->onUserMergeAccountDeleteTables( $tablesToDelete );
673
674        // Make sure these are always set and last
675        if ( $dbw->tableExists( 'actor', __METHOD__ ) ) {
676            $tablesToDelete['actor'] = 'actor_user';
677        }
678        $tablesToDelete['user'] = 'user_id';
679
680        foreach ( $tablesToDelete as $table => $field ) {
681            // Check if a different database object was passed (Echo or Flow)
682            if ( is_array( $field ) ) {
683                $db = $field['db'] ?? $dbw;
684                $field = $field[0];
685            } else {
686                $db = $dbw;
687            }
688            $db->newDeleteQueryBuilder()
689                ->deleteFrom( $table )
690                ->where( [ $field => $this->oldUser->getId() ] )
691                ->caller( __METHOD__ )
692                ->execute();
693        }
694
695        $hookRunner->onDeleteAccount( $this->oldUser );
696
697        DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'users' => -1 ] ) );
698    }
699}