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