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