Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.43% covered (warning)
89.43%
237 / 265
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActorStore
89.43% covered (warning)
89.43%
237 / 265
68.00% covered (warning)
68.00%
17 / 25
87.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 newActorFromRow
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 newActorFromRowFields
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 deleteUserIdentityFromCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActorById
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getUserIdentityByName
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getUserIdentityByUserId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 attachActorId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 detachActorId
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 findActorId
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 findActorIdByName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 findActorIdInternal
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 acquireActorId
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
4.28
 createNewActor
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 acquireSystemActorId
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
7.23
 deleteActor
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
3.07
 normalizeUserName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 validateActorForInsertion
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 setUpRollbackHandler
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 checkDatabaseDomain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getUnknownActor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 newSelectQueryBuilder
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 setAllowCreateIpActors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deprecateInvalidCrossWikiParam
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
4.68
 wikiIdToString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\User;
22
23use CannotCreateActorException;
24use InvalidArgumentException;
25use MediaWiki\Block\HideUserUtils;
26use MediaWiki\DAO\WikiAwareEntity;
27use MediaWiki\User\TempUser\TempUserConfig;
28use Psr\Log\LoggerInterface;
29use stdClass;
30use Wikimedia\Assert\Assert;
31use Wikimedia\IPUtils;
32use Wikimedia\Rdbms\DBQueryError;
33use Wikimedia\Rdbms\IDatabase;
34use Wikimedia\Rdbms\IDBAccessObject;
35use Wikimedia\Rdbms\ILoadBalancer;
36use Wikimedia\Rdbms\IReadableDatabase;
37
38/**
39 * Service for interacting with the actor table.
40 *
41 * @package MediaWiki\User
42 * @since 1.36
43 */
44class ActorStore implements UserIdentityLookup, ActorNormalization {
45
46    public const UNKNOWN_USER_NAME = 'Unknown user';
47
48    private const LOCAL_CACHE_SIZE = 100;
49
50    private ILoadBalancer $loadBalancer;
51    private UserNameUtils $userNameUtils;
52    private TempUserConfig $tempUserConfig;
53    private LoggerInterface $logger;
54    private HideUserUtils $hideUserUtils;
55
56    /** @var string|false */
57    private $wikiId;
58
59    private ActorCache $cache;
60
61    private bool $allowCreateIpActors;
62
63    /**
64     * @param ILoadBalancer $loadBalancer
65     * @param UserNameUtils $userNameUtils
66     * @param TempUserConfig $tempUserConfig
67     * @param LoggerInterface $logger
68     * @param HideUserUtils $hideUserUtils
69     * @param string|false $wikiId
70     */
71    public function __construct(
72        ILoadBalancer $loadBalancer,
73        UserNameUtils $userNameUtils,
74        TempUserConfig $tempUserConfig,
75        LoggerInterface $logger,
76        HideUserUtils $hideUserUtils,
77        $wikiId = WikiAwareEntity::LOCAL
78    ) {
79        Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' );
80
81        $this->loadBalancer = $loadBalancer;
82        $this->userNameUtils = $userNameUtils;
83        $this->tempUserConfig = $tempUserConfig;
84        $this->logger = $logger;
85        $this->hideUserUtils = $hideUserUtils;
86        $this->wikiId = $wikiId;
87
88        $this->cache = new ActorCache( self::LOCAL_CACHE_SIZE );
89
90        $this->allowCreateIpActors = !$this->tempUserConfig->isEnabled();
91    }
92
93    /**
94     * Instantiate a new UserIdentity object based on a $row from the actor table.
95     *
96     * Use this method when an actor row was already fetched from the DB via a join.
97     * This method just constructs a new instance and does not try fetching missing
98     * values from the DB again, use {@link UserIdentityLookup} for that.
99     *
100     * @param stdClass $row with the following fields:
101     *  - int actor_id
102     *  - string actor_name
103     *  - int|null actor_user
104     * @return UserIdentity
105     * @throws InvalidArgumentException
106     */
107    public function newActorFromRow( stdClass $row ): UserIdentity {
108        $actorId = (int)$row->actor_id;
109        $userId = isset( $row->actor_user ) ? (int)$row->actor_user : 0;
110        if ( $actorId === 0 ) {
111            throw new InvalidArgumentException( "Actor ID is 0 for {$row->actor_name} and {$userId}" );
112        }
113
114        $normalizedName = $this->normalizeUserName( $row->actor_name );
115        if ( $normalizedName === null ) {
116            $this->logger->warning( 'Encountered invalid actor name in database', [
117                'user_id' => $userId,
118                'actor_id' => $actorId,
119                'actor_name' => $row->actor_name,
120                'wiki_id' => $this->wikiId ?: 'local'
121            ] );
122            // TODO: once we have guaranteed db only contains valid actor names,
123            // we can skip normalization here - T273933
124            if ( $row->actor_name === '' ) {
125                throw new InvalidArgumentException( "Actor name can not be empty for {$userId} and {$actorId}" );
126            }
127        }
128
129        $actor = new UserIdentityValue( $userId, $row->actor_name, $this->wikiId );
130        $this->cache->add( $actorId, $actor );
131        return $actor;
132    }
133
134    /**
135     * Instantiate a new UserIdentity object based on field values from a DB row.
136     *
137     * Until {@link ActorMigration} is completed, the actor table joins alias actor field names
138     * to legacy field names. This method is convenience to construct the UserIdentity based on
139     * legacy field names. It's more relaxed with typing then ::newFromRow to better support legacy
140     * code, so always prefer ::newFromRow in new code. Eventually, once {@link ActorMigration}
141     * is completed and all queries use explicit join with actor table, this method will be
142     * deprecated and removed.
143     *
144     * @throws InvalidArgumentException
145     * @param int|null $userId
146     * @param string|null $name
147     * @param int|null $actorId
148     * @return UserIdentity
149     */
150    public function newActorFromRowFields( $userId, $name, $actorId ): UserIdentity {
151        // For backwards compatibility we are quite relaxed about what to accept,
152        // but try not to create entirely incorrect objects. As we move more code
153        // from ActorMigration aliases to proper join with the actor table,
154        // we should use ::newActorFromRow more, and eventually deprecate this method.
155        $userId = $userId === null ? 0 : (int)$userId;
156        $name ??= '';
157        if ( $actorId === null ) {
158            throw new InvalidArgumentException( "Actor ID is null for {$name} and {$userId}" );
159        }
160        if ( (int)$actorId === 0 ) {
161            throw new InvalidArgumentException( "Actor ID is 0 for {$name} and {$userId}" );
162        }
163
164        $normalizedName = $this->normalizeUserName( $name );
165        if ( $normalizedName === null ) {
166            $this->logger->warning( 'Encountered invalid actor name in database', [
167                'user_id' => $userId,
168                'actor_id' => $actorId,
169                'actor_name' => $name,
170                'wiki_id' => $this->wikiId ?: 'local'
171            ] );
172            // TODO: once we have guaranteed the DB entries only exist for normalized names,
173            // we can skip normalization here - T273933
174            if ( $name === '' ) {
175                throw new InvalidArgumentException( "Actor name can not be empty for {$userId} and {$actorId}" );
176            }
177        }
178
179        $actorId = (int)$actorId;
180        $actor = new UserIdentityValue(
181            $userId,
182            $name,
183            $this->wikiId
184        );
185
186        $this->cache->add( $actorId, $actor );
187        return $actor;
188    }
189
190    /**
191     * @param UserIdentity $actor
192     * @internal for use in User object only
193     */
194    public function deleteUserIdentityFromCache( UserIdentity $actor ) {
195        $this->cache->remove( $actor );
196    }
197
198    /**
199     * Find an actor by $id.
200     *
201     * @param int $actorId
202     * @param IReadableDatabase $db The database connection to operate on.
203     *        The database must correspond to ActorStore's wiki ID.
204     * @return UserIdentity|null Returns null if no actor with this $actorId exists in the database.
205     */
206    public function getActorById( int $actorId, IReadableDatabase $db ): ?UserIdentity {
207        $this->checkDatabaseDomain( $db );
208
209        if ( !$actorId ) {
210            return null;
211        }
212
213        return $this->cache->getActor( ActorCache::KEY_ACTOR_ID, $actorId ) ??
214            $this->newSelectQueryBuilder( $db )
215                ->caller( __METHOD__ )
216                ->conds( [ 'actor_id' => $actorId ] )
217                ->fetchUserIdentity() ??
218            // The actor ID mostly comes from DB, so if we can't find an actor by ID,
219            // it's most likely due to lagged replica and not cause it doesn't actually exist.
220            // Probably we just inserted it? Try primary database.
221            $this->newSelectQueryBuilder( IDBAccessObject::READ_LATEST )
222                ->caller( __METHOD__ )
223                ->conds( [ 'actor_id' => $actorId ] )
224                ->fetchUserIdentity();
225    }
226
227    /**
228     * Find an actor by $name
229     *
230     * @param string $name
231     * @param int $queryFlags one of IDBAccessObject constants
232     * @return UserIdentity|null
233     */
234    public function getUserIdentityByName(
235        string $name,
236        int $queryFlags = IDBAccessObject::READ_NORMAL
237    ): ?UserIdentity {
238        $normalizedName = $this->normalizeUserName( $name );
239        if ( $normalizedName === null ) {
240            return null;
241        }
242
243        return $this->cache->getActor( ActorCache::KEY_USER_NAME, $normalizedName ) ??
244            $this->newSelectQueryBuilder( $queryFlags )
245                ->caller( __METHOD__ )
246                ->whereUserNames( $normalizedName )
247                ->fetchUserIdentity();
248    }
249
250    /**
251     * Find an actor by $userId
252     *
253     * @param int $userId
254     * @param int $queryFlags one of IDBAccessObject constants
255     * @return UserIdentity|null
256     */
257    public function getUserIdentityByUserId(
258        int $userId,
259        int $queryFlags = IDBAccessObject::READ_NORMAL
260    ): ?UserIdentity {
261        if ( !$userId ) {
262            return null;
263        }
264
265        return $this->cache->getActor( ActorCache::KEY_USER_ID, $userId ) ??
266            $this->newSelectQueryBuilder( $queryFlags )
267                ->caller( __METHOD__ )
268                ->whereUserIds( $userId )
269                ->fetchUserIdentity();
270    }
271
272    /**
273     * Attach the actor ID to $user for backwards compatibility.
274     *
275     * @todo remove this method when no longer needed (T273974).
276     *
277     * @param UserIdentity $user
278     * @param int $id
279     * @param bool $assigned whether a new actor ID was just assigned.
280     */
281    private function attachActorId( UserIdentity $user, int $id, bool $assigned ) {
282        if ( $user instanceof User ) {
283            $user->setActorId( $id );
284            if ( $assigned ) {
285                $user->invalidateCache();
286            }
287        }
288    }
289
290    /**
291     * Detach the actor ID from $user for backwards compatibility.
292     *
293     * @todo remove this method when no longer needed (T273974).
294     *
295     * @param UserIdentity $user
296     */
297    private function detachActorId( UserIdentity $user ) {
298        if ( $user instanceof User ) {
299            $user->setActorId( 0 );
300        }
301    }
302
303    /**
304     * Find the actor_id of the given $user.
305     *
306     * @param UserIdentity $user
307     * @param IReadableDatabase $db The database connection to operate on.
308     *        The database must correspond to ActorStore's wiki ID.
309     * @return int|null
310     */
311    public function findActorId( UserIdentity $user, IReadableDatabase $db ): ?int {
312        // TODO: we want to assert this user belongs to the correct wiki,
313        // but User objects are always local and we used to use them
314        // on a non-local DB connection. We need to first deprecate this
315        // possibility and then throw on mismatching User object - T273972
316        // $user->assertWiki( $this->wikiId );
317        $this->deprecateInvalidCrossWikiParam( $user );
318
319        // TODO: In the future we would be able to assume UserIdentity name is ok
320        // and will be able to skip normalization here - T273933
321        $name = $this->normalizeUserName( $user->getName() );
322        if ( $name === null ) {
323            $this->logger->warning( 'Encountered a UserIdentity with invalid name', [
324                'user_name' => $user->getName()
325            ] );
326            return null;
327        }
328
329        $id = $this->findActorIdInternal( $name, $db );
330
331        // Set the actor ID in the User object. To be removed, see T274148.
332        if ( $id && $user instanceof User ) {
333            $user->setActorId( $id );
334        }
335
336        return $id;
337    }
338
339    /**
340     * Find the actor_id of the given $name.
341     *
342     * @param string $name
343     * @param IReadableDatabase $db The database connection to operate on.
344     *        The database must correspond to ActorStore's wiki ID.
345     * @return int|null
346     */
347    public function findActorIdByName( $name, IReadableDatabase $db ): ?int {
348        $name = $this->normalizeUserName( $name );
349        if ( $name === null ) {
350            return null;
351        }
352
353        return $this->findActorIdInternal( $name, $db );
354    }
355
356    /**
357     * Find actor_id of the given $user using the passed $db connection.
358     *
359     * @param string $name
360     * @param IReadableDatabase $db The database connection to operate on.
361     *        The database must correspond to ActorStore's wiki ID.
362     * @param bool $lockInShareMode
363     * @return int|null
364     */
365    private function findActorIdInternal(
366        string $name,
367        IReadableDatabase $db,
368        bool $lockInShareMode = false
369    ): ?int {
370        // Note: UserIdentity::getActorId will be deprecated and removed,
371        // and this is the replacement for it. Can't call User::getActorId, cause
372        // User always thinks it's local, so we could end up fetching the ID
373        // from the wrong database.
374
375        $cachedValue = $this->cache->getActorId( ActorCache::KEY_USER_NAME, $name );
376        if ( $cachedValue ) {
377            return $cachedValue;
378        }
379
380        $queryBuilder = $db->newSelectQueryBuilder()
381            ->select( [ 'actor_user', 'actor_name', 'actor_id' ] )
382            ->from( 'actor' )
383            ->where( [ 'actor_name' => $name ] );
384        if ( $lockInShareMode ) {
385            $queryBuilder->lockInShareMode();
386        }
387
388        $row = $queryBuilder->caller( __METHOD__ )->fetchRow();
389
390        if ( !$row || !$row->actor_id ) {
391            return null;
392        }
393        // to cache row
394        $this->newActorFromRow( $row );
395
396        return (int)$row->actor_id;
397    }
398
399    /**
400     * Attempt to assign an actor ID to the given $user. If it is already assigned,
401     * return the existing ID.
402     *
403     * @note If called within a transaction, the returned ID might become invalid
404     * if the transaction is rolled back, so it should not be passed outside of the
405     * transaction context.
406     *
407     * @param UserIdentity $user
408     * @param IDatabase $dbw The database connection to acquire the ID from.
409     *        The database must correspond to ActorStore's wiki ID.
410     * @return int actor ID greater then 0
411     * @throws CannotCreateActorException if no actor ID has been assigned to this $user
412     */
413    public function acquireActorId( UserIdentity $user, IDatabase $dbw ): int {
414        $this->checkDatabaseDomain( $dbw );
415        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
416
417        // allow cache to be used, because if it is in the cache, it already has an actor ID
418        $existingActorId = $this->findActorIdInternal( $userName, $dbw );
419        if ( $existingActorId ) {
420            $this->attachActorId( $user, $existingActorId, false );
421            return $existingActorId;
422        }
423
424        $dbw->newInsertQueryBuilder()
425            ->insertInto( 'actor' )
426            ->ignore()
427            ->row( [ 'actor_user' => $userId, 'actor_name' => $userName ] )
428            ->caller( __METHOD__ )->execute();
429
430        if ( $dbw->affectedRows() ) {
431            $actorId = $dbw->insertId();
432        } else {
433            // Outdated cache?
434            // Use LOCK IN SHARE MODE to bypass any MySQL REPEATABLE-READ snapshot.
435            $actorId = $this->findActorIdInternal( $userName, $dbw, true );
436            if ( !$actorId ) {
437                throw new CannotCreateActorException(
438                    "Failed to create actor ID for " .
439                    "user_id={$userId} user_name=\"{$userName}\""
440                );
441            }
442        }
443
444        $this->attachActorId( $user, $actorId, true );
445        // Cache row we've just created
446        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
447        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
448        return $actorId;
449    }
450
451    /**
452     * Create a new actor for the given $user. If an actor with this name already exists,
453     * this method throws.
454     *
455     * @note If called within a transaction, the returned ID might become invalid
456     * if the transaction is rolled back, so it should not be passed outside of the
457     * transaction context.
458     *
459     * @param UserIdentity $user
460     * @param IDatabase $dbw
461     * @return int actor ID greater then 0
462     * @throws CannotCreateActorException if an actor with this name already exist.
463     * @internal for use in user account creation only.
464     */
465    public function createNewActor( UserIdentity $user, IDatabase $dbw ): int {
466        $this->checkDatabaseDomain( $dbw );
467        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
468
469        try {
470            $dbw->newInsertQueryBuilder()
471                ->insertInto( 'actor' )
472                ->row( [ 'actor_user' => $userId, 'actor_name' => $userName ] )
473                ->caller( __METHOD__ )->execute();
474        } catch ( DBQueryError $e ) {
475            // We rely on the database to crash on unique actor_name constraint.
476            throw new CannotCreateActorException( $e->getMessage() );
477        }
478        $actorId = $dbw->insertId();
479
480        $this->attachActorId( $user, $actorId, true );
481        // Cache row we've just created
482        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
483        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
484
485        return $actorId;
486    }
487
488    /**
489     * Attempt to assign an ID to an actor for a system user. If an actor ID already
490     * exists, return it.
491     *
492     * @note For reserved user names this method will overwrite the user ID of the
493     * existing anon actor.
494     *
495     * @note If called within a transaction, the returned ID might become invalid
496     * if the transaction is rolled back, so it should not be passed outside of the
497     * transaction context.
498     *
499     * @param UserIdentity $user
500     * @param IDatabase $dbw
501     * @return int actor ID greater then zero
502     * @throws CannotCreateActorException if the existing actor is associated with registered user.
503     * @internal for use in user account creation only.
504     */
505    public function acquireSystemActorId( UserIdentity $user, IDatabase $dbw ): int {
506        $this->checkDatabaseDomain( $dbw );
507        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
508
509        $existingActorId = $this->findActorIdInternal( $userName, $dbw );
510        if ( $existingActorId ) {
511            // It certainly will be cached if we just found it.
512            $existingActor = $this->cache->getActor( ActorCache::KEY_ACTOR_ID, $existingActorId );
513
514            // If we already have an existing actor with a matching user ID
515            // just return it, nothing to do here.
516            if ( $existingActor->getId( $this->wikiId ) === $user->getId( $this->wikiId ) ) {
517                return $existingActorId;
518            }
519
520            // Allow overwriting user ID for already existing actor with reserved user name, see T236444
521            if ( $this->userNameUtils->isUsable( $userName ) || $existingActor->isRegistered() ) {
522                throw new CannotCreateActorException(
523                    'Cannot replace user for existing actor: ' .
524                    "actor_id=$existingActorId, new user_id=$userId"
525                );
526            }
527        }
528        $dbw->newInsertQueryBuilder()
529            ->insertInto( 'actor' )
530            ->row( [ 'actor_name' => $userName, 'actor_user' => $userId ] )
531            ->onDuplicateKeyUpdate()
532            ->uniqueIndexFields( [ 'actor_name' ] )
533            ->set( [ 'actor_user' => $userId ] )
534            ->caller( __METHOD__ )->execute();
535        if ( !$dbw->affectedRows() ) {
536            throw new CannotCreateActorException(
537                'Failed to replace user for actor: ' .
538                "actor_id=$existingActorId, new user_id=$userId"
539            );
540        }
541        $actorId = $dbw->insertId() ?: $existingActorId;
542
543        $this->cache->remove( $user );
544        $this->attachActorId( $user, $actorId, true );
545        // Cache row we've just created
546        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
547        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
548        return $actorId;
549    }
550
551    /**
552     * Delete the actor from the actor table
553     *
554     * @warning this method does very limited validation and is extremely
555     * dangerous since it can break referential integrity of the database
556     * if used incorrectly. Use at your own risk!
557     *
558     * @since 1.37
559     * @param UserIdentity $actor
560     * @param IDatabase $dbw
561     * @return bool true on success, false if nothing was deleted.
562     */
563    public function deleteActor( UserIdentity $actor, IDatabase $dbw ): bool {
564        $this->checkDatabaseDomain( $dbw );
565        $this->deprecateInvalidCrossWikiParam( $actor );
566
567        $normalizedName = $this->normalizeUserName( $actor->getName() );
568        if ( $normalizedName === null ) {
569            throw new InvalidArgumentException(
570                "Unable to normalize the provided actor name {$actor->getName()}"
571            );
572        }
573        $dbw->newDeleteQueryBuilder()
574            ->deleteFrom( 'actor' )
575            ->where( [ 'actor_name' => $normalizedName ] )
576            ->caller( __METHOD__ )->execute();
577        if ( $dbw->affectedRows() !== 0 ) {
578            $this->cache->remove( $actor );
579            return true;
580        }
581        return false;
582    }
583
584    /**
585     * Returns a canonical form of user name suitable for storage.
586     *
587     * @internal
588     * @param string $name
589     *
590     * @return string|null
591     */
592    public function normalizeUserName( string $name ): ?string {
593        if ( $this->userNameUtils->isIP( $name ) ) {
594            return IPUtils::sanitizeIP( $name );
595        } elseif ( ExternalUserNames::isExternal( $name ) ) {
596            // TODO: ideally, we should probably canonicalize external usernames,
597            // but it was not done before, so we can not start doing it unless we
598            // fix existing DB rows - T273933
599            return $name;
600        } else {
601            $normalized = $this->userNameUtils->getCanonical( $name );
602            return $normalized === false ? null : $normalized;
603        }
604    }
605
606    /**
607     * Validates actor before insertion.
608     *
609     * @param UserIdentity $user
610     * @return array [ $normalizedUserId, $normalizedName ]
611     */
612    private function validateActorForInsertion( UserIdentity $user ): array {
613        // TODO: we want to assert this user belongs to the correct wiki,
614        // but User objects are always local and we used to use them
615        // on a non-local DB connection. We need to first deprecate this
616        // possibility and then throw on mismatching User object - T273972
617        // $user->assertWiki( $this->wikiId );
618        $this->deprecateInvalidCrossWikiParam( $user );
619
620        $userName = $this->normalizeUserName( $user->getName() );
621        if ( $userName === null || $userName === '' ) {
622            $userIdForErrorMessage = $user->getId( $this->wikiId );
623            throw new CannotCreateActorException(
624                'Cannot create an actor for a user with no name: ' .
625                "user_id={$userIdForErrorMessage} user_name=\"{$user->getName()}\""
626            );
627        }
628
629        $userId = $user->getId( $this->wikiId ) ?: null;
630        if ( $userId === null && $this->userNameUtils->isUsable( $user->getName() ) ) {
631            throw new CannotCreateActorException(
632                'Cannot create an actor for a usable name that is not an existing user: ' .
633                "user_name=\"{$user->getName()}\""
634            );
635        }
636
637        if ( !$this->allowCreateIpActors && $this->userNameUtils->isIP( $userName ) ) {
638            throw new CannotCreateActorException(
639                'Cannot create an actor for an IP user when temporary accounts are enabled'
640            );
641        }
642        return [ $userId, $userName ];
643    }
644
645    /**
646     * Clear in-process caches if transaction gets rolled back.
647     *
648     * @param IDatabase $dbw
649     * @param UserIdentity $cachedActor
650     * @param UserIdentity $originalActor
651     */
652    private function setUpRollbackHandler(
653        IDatabase $dbw,
654        UserIdentity $cachedActor,
655        UserIdentity $originalActor
656    ) {
657        if ( $dbw->trxLevel() ) {
658            // If called within a transaction and it was rolled back, the cached actor ID
659            // becomes invalid, so cache needs to be invalidated as well. See T277795.
660            $dbw->onTransactionResolution(
661                function ( int $trigger ) use ( $cachedActor, $originalActor ) {
662                    if ( $trigger === IDatabase::TRIGGER_ROLLBACK ) {
663                        $this->cache->remove( $cachedActor );
664                        $this->detachActorId( $originalActor );
665                    }
666                },
667                __METHOD__
668            );
669        }
670    }
671
672    /**
673     * Throws an exception if the given database connection does not belong to the wiki this
674     * ActorStore is bound to.
675     *
676     * @param IReadableDatabase $db
677     */
678    private function checkDatabaseDomain( IReadableDatabase $db ) {
679        $dbDomain = $db->getDomainID();
680        $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId );
681        if ( $dbDomain !== $storeDomain ) {
682            throw new InvalidArgumentException(
683                "DB connection domain '$dbDomain' does not match '$storeDomain'"
684            );
685        }
686    }
687
688    /**
689     * In case all reasonable attempts of initializing a proper actor from the
690     * database have failed, entities can be attributed to special 'Unknown user' actor.
691     *
692     * @return UserIdentity
693     */
694    public function getUnknownActor(): UserIdentity {
695        $actor = $this->getUserIdentityByName( self::UNKNOWN_USER_NAME );
696        if ( $actor ) {
697            return $actor;
698        }
699        $actor = new UserIdentityValue( 0, self::UNKNOWN_USER_NAME, $this->wikiId );
700
701        $db = $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
702        $this->acquireActorId( $actor, $db );
703        return $actor;
704    }
705
706    /**
707     * Returns a specialized SelectQueryBuilder for querying the UserIdentity objects.
708     *
709     * @param IReadableDatabase|int $dbOrQueryFlags The database connection to perform the query on,
710     *   or one of IDBAccessObject::READ_* constants.
711     * @return UserSelectQueryBuilder
712     */
713    public function newSelectQueryBuilder( $dbOrQueryFlags = IDBAccessObject::READ_NORMAL ): UserSelectQueryBuilder {
714        if ( $dbOrQueryFlags instanceof IReadableDatabase ) {
715            [ $db, $flags ] = [ $dbOrQueryFlags, IDBAccessObject::READ_NORMAL ];
716            $this->checkDatabaseDomain( $db );
717        } else {
718            if ( ( $dbOrQueryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
719                $db = $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
720            } else {
721                $db = $this->loadBalancer->getConnection( DB_REPLICA, [], $this->wikiId );
722            }
723            $flags = $dbOrQueryFlags;
724        }
725
726        $builder = new UserSelectQueryBuilder(
727            $db,
728            $this,
729            $this->tempUserConfig,
730            $this->hideUserUtils
731        );
732        return $builder->recency( $flags );
733    }
734
735    /**
736     * @internal For use immediately after construction only
737     * @param bool $allow
738     */
739    public function setAllowCreateIpActors( bool $allow ): void {
740        $this->allowCreateIpActors = $allow;
741    }
742
743    /**
744     * Emits a deprecation warning if $user does not belong to the
745     * same wiki this store belongs to.
746     *
747     * @param UserIdentity $user
748     */
749    private function deprecateInvalidCrossWikiParam( UserIdentity $user ) {
750        if ( $user->getWikiId() !== $this->wikiId ) {
751            $expected = $this->wikiIdToString( $user->getWikiId() );
752            $actual = $this->wikiIdToString( $this->wikiId );
753            wfDeprecatedMsg(
754                'Deprecated passing invalid cross-wiki user. ' .
755                "Expected: {$expected}, Actual: {$actual}.",
756                '1.37'
757            );
758        }
759    }
760
761    /**
762     * Convert $wikiId to a string for logging.
763     *
764     * @param string|false $wikiId
765     * @return string
766     */
767    private function wikiIdToString( $wikiId ): string {
768        return $wikiId === WikiAwareEntity::LOCAL ? 'the local wiki' : "'{$wikiId}'";
769    }
770}