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