Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.60% covered (success)
95.60%
87 / 91
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserEditTracker
95.60% covered (success)
95.60%
87 / 91
66.67% covered (warning)
66.67%
8 / 12
32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getUserEditCount
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 preloadUserEditCountCache
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 initializeUserEditCount
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 incrementUserEditCount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getFirstEditTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLatestEditTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserEditTimestamp
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 clearUserEditCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setCachedUserEditCount
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCacheKey
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCacheKeyByUserId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\User;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\DAO\WikiAwareEntity;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Deferred\UserEditCountUpdate;
10use MediaWiki\JobQueue\JobQueueGroup;
11use MediaWiki\WikiMap\WikiMap;
12use Wikimedia\Rdbms\IConnectionProvider;
13use Wikimedia\Rdbms\IDBAccessObject;
14use Wikimedia\Rdbms\SelectQueryBuilder;
15use Wikimedia\Timestamp\ConvertibleTimestamp;
16use Wikimedia\Timestamp\TimestampFormat as TS;
17
18/**
19 * Track info about user edit counts and timings
20 *
21 * @since 1.35
22 * @ingroup User
23 * @author DannyS712
24 */
25class UserEditTracker {
26
27    private const FIRST_EDIT = 1;
28    private const LATEST_EDIT = 2;
29
30    private ActorNormalization $actorNormalization;
31    private IConnectionProvider $dbProvider;
32    private JobQueueGroup $jobQueueGroup;
33
34    /**
35     * @var int[]
36     *
37     * Mapping of user id to edit count for caching
38     * The keys are in one of the forms:
39     * * `u{user_id}` - for registered users from the local wiki
40     * * `{wiki_id}:u{user_id}` - for registered users from other wikis
41     */
42    private $userEditCountCache = [];
43
44    /**
45     * @param ActorNormalization $actorNormalization
46     * @param IConnectionProvider $dbProvider
47     * @param JobQueueGroup $jobQueueGroup
48     */
49    public function __construct(
50        ActorNormalization $actorNormalization,
51        IConnectionProvider $dbProvider,
52        JobQueueGroup $jobQueueGroup
53    ) {
54        $this->actorNormalization = $actorNormalization;
55        $this->dbProvider = $dbProvider;
56        $this->jobQueueGroup = $jobQueueGroup;
57    }
58
59    /**
60     * Get a user's edit count from the user_editcount field, falling back to initialize
61     *
62     * @param UserIdentity $user
63     * @return int|null Null for anonymous users
64     */
65    public function getUserEditCount( UserIdentity $user ): ?int {
66        if ( !$user->isRegistered() ) {
67            return null;
68        }
69
70        $cacheKey = $this->getCacheKey( $user );
71        if ( isset( $this->userEditCountCache[ $cacheKey ] ) ) {
72            return $this->userEditCountCache[ $cacheKey ];
73        }
74
75        $wikiId = $user->getWikiId();
76        $userId = $user->getId( $wikiId );
77        $count = $this->dbProvider->getReplicaDatabase( $wikiId )->newSelectQueryBuilder()
78            ->select( 'user_editcount' )
79            ->from( 'user' )
80            ->where( [ 'user_id' => $userId ] )
81            ->caller( __METHOD__ )->fetchField();
82
83        if ( $count === null ) {
84            // it has not been initialized. do so.
85            $count = $this->initializeUserEditCount( $user );
86        }
87
88        $this->userEditCountCache[ $cacheKey ] = $count;
89        return $count;
90    }
91
92    /**
93     * Preloads the internal edit count cache for the given users.
94     *
95     * Use this when calls to {@link self::getUserEditCount()} are expected for
96     * multiple users, so that the queries can be batched instead of performing
97     * one query per user.
98     *
99     * Unlike {@link self::getUserEditCount()}, this will not try to update the
100     * edit counts stored in user_editcount for users for which the count was
101     * not previously initialized.
102     *
103     * @param UserIdentity[] $users
104     * @since 1.46
105     * @return void
106     */
107    public function preloadUserEditCountCache( array $users ): void {
108        $userIds = [];
109
110        foreach ( $users as $user ) {
111            if (
112                $user->isRegistered() &&
113                $user->getWikiId() === UserIdentity::LOCAL
114            ) {
115                $userIds[] = $user->getId();
116            }
117        }
118
119        $userIds = array_unique( $userIds );
120
121        $dbr = $this->dbProvider->getReplicaDatabase();
122
123        foreach ( array_chunk( $userIds, 500 ) as $batch ) {
124            $rows = $dbr->newSelectQueryBuilder()
125                ->select( [ 'user_id', 'user_editcount' ] )
126                ->from( 'user' )
127                ->where( [ 'user_id' => $batch ] )
128                ->caller( __METHOD__ )
129                ->fetchResultSet();
130
131            foreach ( $rows as $row ) {
132                if ( $row->user_editcount !== null ) {
133                    $key = $this->getCacheKeyByUserId( (int)$row->user_id );
134
135                    $this->userEditCountCache[$key] = (int)$row->user_editcount;
136                }
137            }
138        }
139    }
140
141    /**
142     * @internal For use in UserEditCountUpdate class
143     * @param UserIdentity $user
144     * @return int
145     */
146    public function initializeUserEditCount( UserIdentity $user ): int {
147        if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
148            // Don't record edits on remote wikis
149            throw new LogicException( __METHOD__ . ' only supports local users' );
150        }
151
152        $dbr = $this->dbProvider->getReplicaDatabase();
153        $count = (int)$dbr->newSelectQueryBuilder()
154            ->select( 'COUNT(*)' )
155            ->from( 'revision' )
156            ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $dbr ) ] )
157            ->caller( __METHOD__ )
158            ->fetchField();
159
160        // Defer updating the edit count via a job (T259719)
161        $this->jobQueueGroup->push( new UserEditCountInitJob( [
162            'userId' => $user->getId(),
163            'editCount' => $count,
164        ] ) );
165
166        return $count;
167    }
168
169    /**
170     * Schedule a job to increase a user's edit count
171     *
172     * @since 1.37
173     * @param UserIdentity $user
174     */
175    public function incrementUserEditCount( UserIdentity $user ) {
176        if ( !$user->isRegistered() ) {
177            // Can't store editcount without user row (i.e. unregistered)
178            return;
179        }
180
181        DeferredUpdates::addUpdate(
182            new UserEditCountUpdate( $user, 1 ),
183            DeferredUpdates::POSTSEND
184        );
185    }
186
187    /**
188     * Get the user's first edit timestamp
189     *
190     * @param UserIdentity $user
191     * @param int $flags bit field, see IDBAccessObject::READ_XXX
192     * @return string|false Timestamp of first edit, or false for non-existent/anonymous user
193     *  accounts.
194     */
195    public function getFirstEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
196        return $this->getUserEditTimestamp( $user, self::FIRST_EDIT, $flags );
197    }
198
199    /**
200     * Get the user's latest edit timestamp
201     *
202     * @param UserIdentity $user
203     * @param int $flags bit field, see IDBAccessObject::READ_XXX
204     * @return string|false Timestamp of latest edit, or false for non-existent/anonymous user
205     *  accounts.
206     */
207    public function getLatestEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
208        return $this->getUserEditTimestamp( $user, self::LATEST_EDIT, $flags );
209    }
210
211    /**
212     * Get the timestamp of a user's edit, either their first or latest
213     *
214     * @param UserIdentity $user
215     * @param int $type either self::FIRST_EDIT or ::LATEST_EDIT
216     * @param int $flags bit field, see IDBAccessObject::READ_XXX
217     * @return string|false Timestamp of edit, or false for non-existent/anonymous user accounts.
218     */
219    private function getUserEditTimestamp( UserIdentity $user, int $type, int $flags = IDBAccessObject::READ_NORMAL ) {
220        if ( !$user->isRegistered() ) {
221            return false;
222        }
223        if ( $flags & IDBAccessObject::READ_LATEST ) {
224            $db = $this->dbProvider->getPrimaryDatabase( $user->getWikiId() );
225        } else {
226            $db = $this->dbProvider->getReplicaDatabase( $user->getWikiId() );
227        }
228
229        $sortOrder = ( $type === self::FIRST_EDIT ) ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC;
230        $time = $db->newSelectQueryBuilder()
231            ->select( 'rev_timestamp' )
232            ->from( 'revision' )
233            ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $db ) ] )
234            ->orderBy( 'rev_timestamp', $sortOrder )
235            ->caller( __METHOD__ )
236            ->fetchField();
237
238        if ( !$time ) {
239            return false; // no edits
240        }
241
242        return ConvertibleTimestamp::convert( TS::MW, $time );
243    }
244
245    /**
246     * @internal For use by User::clearInstanceCache()
247     * @param UserIdentity $user
248     */
249    public function clearUserEditCache( UserIdentity $user ) {
250        if ( !$user->isRegistered() ) {
251            return;
252        }
253
254        $cacheKey = $this->getCacheKey( $user );
255        unset( $this->userEditCountCache[ $cacheKey ] );
256    }
257
258    /**
259     * @internal For use by User::loadFromRow() and tests
260     * @param UserIdentity $user
261     * @param int $editCount
262     * @throws InvalidArgumentException If the user is not registered
263     */
264    public function setCachedUserEditCount( UserIdentity $user, int $editCount ) {
265        if ( !$user->isRegistered() ) {
266            throw new InvalidArgumentException( __METHOD__ . ' with an anonymous user' );
267        }
268
269        $cacheKey = $this->getCacheKey( $user );
270        $this->userEditCountCache[ $cacheKey ] = $editCount;
271    }
272
273    /**
274     * Returns the cache key to be used for reading from or updating the edit
275     * count cache for a given user, provided as a UserIdentity instance.
276     *
277     * @param UserIdentity $user User to get the cache key for.
278     * @return string
279     */
280    private function getCacheKey( UserIdentity $user ): string {
281        if ( !$user->isRegistered() ) {
282            throw new InvalidArgumentException( 'Cannot prepare cache key for an anonymous user' );
283        }
284
285        $wikiId = $user->getWikiId();
286
287        return $this->getCacheKeyByUserId( $user->getId( $wikiId ), $wikiId );
288    }
289
290    /**
291     * Returns the cache key to be used for reading from or updating the edit
292     * count cache for a given user, identified by its user ID and the ID of the
293     * wiki it belongs to.
294     *
295     * @param int $userId ID of the user to get the cache key for.
296     * @param string|false $wikiId ID of the wiki the user belongs to.
297     * @return string
298     */
299    private function getCacheKeyByUserId(
300        int $userId,
301        string|bool $wikiId = WikiAwareEntity::LOCAL
302    ): string {
303        $isRemoteWiki = ( $wikiId !== UserIdentity::LOCAL ) && !WikiMap::isCurrentWikiId( $wikiId );
304        if ( $isRemoteWiki ) {
305            return $wikiId . ':u' . $userId;
306        }
307        return 'u' . $userId;
308    }
309}