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