Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.52% covered (success)
94.52%
69 / 73
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserEditTracker
94.52% covered (success)
94.52%
69 / 73
60.00% covered (warning)
60.00%
6 / 10
24.09
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
 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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3namespace MediaWiki\User;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\Deferred\DeferredUpdates;
8use MediaWiki\Deferred\UserEditCountUpdate;
9use MediaWiki\JobQueue\JobQueueGroup;
10use MediaWiki\WikiMap\WikiMap;
11use Wikimedia\Rdbms\IConnectionProvider;
12use Wikimedia\Rdbms\IDBAccessObject;
13use Wikimedia\Rdbms\SelectQueryBuilder;
14use Wikimedia\Timestamp\ConvertibleTimestamp;
15
16/**
17 * Track info about user edit counts and timings
18 *
19 * @since 1.35
20 * @ingroup User
21 * @author DannyS712
22 */
23class UserEditTracker {
24
25    private const FIRST_EDIT = 1;
26    private const LATEST_EDIT = 2;
27
28    private ActorNormalization $actorNormalization;
29    private IConnectionProvider $dbProvider;
30    private JobQueueGroup $jobQueueGroup;
31
32    /**
33     * @var int[]
34     *
35     * Mapping of user id to edit count for caching
36     * The keys are in one of the forms:
37     * * `u{user_id}` - for registered users from the local wiki
38     * * `{wiki_id}:u{user_id}` - for registered users from other wikis
39     */
40    private $userEditCountCache = [];
41
42    /**
43     * @param ActorNormalization $actorNormalization
44     * @param IConnectionProvider $dbProvider
45     * @param JobQueueGroup $jobQueueGroup
46     */
47    public function __construct(
48        ActorNormalization $actorNormalization,
49        IConnectionProvider $dbProvider,
50        JobQueueGroup $jobQueueGroup
51    ) {
52        $this->actorNormalization = $actorNormalization;
53        $this->dbProvider = $dbProvider;
54        $this->jobQueueGroup = $jobQueueGroup;
55    }
56
57    /**
58     * Get a user's edit count from the user_editcount field, falling back to initialize
59     *
60     * @param UserIdentity $user
61     * @return int|null Null for anonymous users
62     */
63    public function getUserEditCount( UserIdentity $user ): ?int {
64        if ( !$user->isRegistered() ) {
65            return null;
66        }
67
68        $cacheKey = $this->getCacheKey( $user );
69        if ( isset( $this->userEditCountCache[ $cacheKey ] ) ) {
70            return $this->userEditCountCache[ $cacheKey ];
71        }
72
73        $wikiId = $user->getWikiId();
74        $userId = $user->getId( $wikiId );
75        $count = $this->dbProvider->getReplicaDatabase( $wikiId )->newSelectQueryBuilder()
76            ->select( 'user_editcount' )
77            ->from( 'user' )
78            ->where( [ 'user_id' => $userId ] )
79            ->caller( __METHOD__ )->fetchField();
80
81        if ( $count === null ) {
82            // it has not been initialized. do so.
83            $count = $this->initializeUserEditCount( $user );
84        }
85
86        $this->userEditCountCache[ $cacheKey ] = $count;
87        return $count;
88    }
89
90    /**
91     * @internal For use in UserEditCountUpdate class
92     * @param UserIdentity $user
93     * @return int
94     */
95    public function initializeUserEditCount( UserIdentity $user ): int {
96        if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
97            // Don't record edits on remote wikis
98            throw new LogicException( __METHOD__ . ' only supports local users' );
99        }
100
101        $dbr = $this->dbProvider->getReplicaDatabase();
102        $count = (int)$dbr->newSelectQueryBuilder()
103            ->select( 'COUNT(*)' )
104            ->from( 'revision' )
105            ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $dbr ) ] )
106            ->caller( __METHOD__ )
107            ->fetchField();
108
109        // Defer updating the edit count via a job (T259719)
110        $this->jobQueueGroup->push( new UserEditCountInitJob( [
111            'userId' => $user->getId(),
112            'editCount' => $count,
113        ] ) );
114
115        return $count;
116    }
117
118    /**
119     * Schedule a job to increase a user's edit count
120     *
121     * @since 1.37
122     * @param UserIdentity $user
123     */
124    public function incrementUserEditCount( UserIdentity $user ) {
125        if ( !$user->isRegistered() ) {
126            // Can't store editcount without user row (i.e. unregistered)
127            return;
128        }
129
130        DeferredUpdates::addUpdate(
131            new UserEditCountUpdate( $user, 1 ),
132            DeferredUpdates::POSTSEND
133        );
134    }
135
136    /**
137     * Get the user's first edit timestamp
138     *
139     * @param UserIdentity $user
140     * @param int $flags bit field, see IDBAccessObject::READ_XXX
141     * @return string|false Timestamp of first edit, or false for non-existent/anonymous user
142     *  accounts.
143     */
144    public function getFirstEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
145        return $this->getUserEditTimestamp( $user, self::FIRST_EDIT, $flags );
146    }
147
148    /**
149     * Get the user's latest edit timestamp
150     *
151     * @param UserIdentity $user
152     * @param int $flags bit field, see IDBAccessObject::READ_XXX
153     * @return string|false Timestamp of latest edit, or false for non-existent/anonymous user
154     *  accounts.
155     */
156    public function getLatestEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
157        return $this->getUserEditTimestamp( $user, self::LATEST_EDIT, $flags );
158    }
159
160    /**
161     * Get the timestamp of a user's edit, either their first or latest
162     *
163     * @param UserIdentity $user
164     * @param int $type either self::FIRST_EDIT or ::LATEST_EDIT
165     * @param int $flags bit field, see IDBAccessObject::READ_XXX
166     * @return string|false Timestamp of edit, or false for non-existent/anonymous user accounts.
167     */
168    private function getUserEditTimestamp( UserIdentity $user, int $type, int $flags = IDBAccessObject::READ_NORMAL ) {
169        if ( !$user->isRegistered() ) {
170            return false;
171        }
172        if ( $flags & IDBAccessObject::READ_LATEST ) {
173            $db = $this->dbProvider->getPrimaryDatabase( $user->getWikiId() );
174        } else {
175            $db = $this->dbProvider->getReplicaDatabase( $user->getWikiId() );
176        }
177
178        $sortOrder = ( $type === self::FIRST_EDIT ) ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC;
179        $time = $db->newSelectQueryBuilder()
180            ->select( 'rev_timestamp' )
181            ->from( 'revision' )
182            ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $db ) ] )
183            ->orderBy( 'rev_timestamp', $sortOrder )
184            ->caller( __METHOD__ )
185            ->fetchField();
186
187        if ( !$time ) {
188            return false; // no edits
189        }
190
191        return ConvertibleTimestamp::convert( TS_MW, $time );
192    }
193
194    /**
195     * @internal For use by User::clearInstanceCache()
196     * @param UserIdentity $user
197     */
198    public function clearUserEditCache( UserIdentity $user ) {
199        if ( !$user->isRegistered() ) {
200            return;
201        }
202
203        $cacheKey = $this->getCacheKey( $user );
204        unset( $this->userEditCountCache[ $cacheKey ] );
205    }
206
207    /**
208     * @internal For use by User::loadFromRow() and tests
209     * @param UserIdentity $user
210     * @param int $editCount
211     * @throws InvalidArgumentException If the user is not registered
212     */
213    public function setCachedUserEditCount( UserIdentity $user, int $editCount ) {
214        if ( !$user->isRegistered() ) {
215            throw new InvalidArgumentException( __METHOD__ . ' with an anonymous user' );
216        }
217
218        $cacheKey = $this->getCacheKey( $user );
219        $this->userEditCountCache[ $cacheKey ] = $editCount;
220    }
221
222    private function getCacheKey( UserIdentity $user ): string {
223        if ( !$user->isRegistered() ) {
224            throw new InvalidArgumentException( 'Cannot prepare cache key for an anonymous user' );
225        }
226
227        $wikiId = $user->getWikiId();
228        $userId = $user->getId( $wikiId );
229        $isRemoteWiki = ( $wikiId !== UserIdentity::LOCAL ) && !WikiMap::isCurrentWikiId( $wikiId );
230        if ( $isRemoteWiki ) {
231            return $wikiId . ':u' . $userId;
232        }
233        return 'u' . $userId;
234    }
235}