Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.44% covered (success)
93.44%
57 / 61
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MentorStore
93.44% covered (success)
93.44%
57 / 61
75.00% covered (warning)
75.00%
9 / 12
17.08
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
 makeLoadMentorCacheKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 invalidateMentorCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validateMentorRole
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadMentorUser
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 loadMentorUserUncached
n/a
0 / 0
n/a
0 / 0
0
 getMenteesByMentor
n/a
0 / 0
n/a
0 / 0
0
 hasAnyMentees
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setMentorForUser
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 setMentorForUserInternal
n/a
0 / 0
n/a
0 / 0
0
 dropMenteeRelationship
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isMentee
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 makeIsMenteeActiveCacheKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 invalidateIsMenteeActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMenteeActive
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isMenteeActiveUncached
n/a
0 / 0
n/a
0 / 0
0
 markMenteeAsActive
n/a
0 / 0
n/a
0 / 0
0
 markMenteeAsInactive
n/a
0 / 0
n/a
0 / 0
0
1<?php
2
3namespace GrowthExperiments\Mentorship\Store;
4
5use DBAccessObjectUtils;
6use IDBAccessObject;
7use InvalidArgumentException;
8use MediaWiki\User\UserIdentity;
9use Psr\Log\LoggerAwareInterface;
10use Psr\Log\LoggerAwareTrait;
11use Psr\Log\NullLogger;
12use WANObjectCache;
13use Wikimedia\LightweightObjectStore\ExpirationAwareness;
14
15abstract class MentorStore implements ExpirationAwareness, LoggerAwareInterface {
16    use LoggerAwareTrait;
17
18    /** @var string */
19    public const ROLE_PRIMARY = 'primary';
20
21    /** @var string */
22    public const ROLE_BACKUP = 'backup';
23
24    /** @var string[] */
25    public const ROLES = [
26        self::ROLE_PRIMARY,
27        self::ROLE_BACKUP
28    ];
29
30    /** @var WANObjectCache */
31    protected WANObjectCache $wanCache;
32    /** @var array Cache key =>¨value; custom in-process cache */
33    protected array $inProcessCache = [];
34    /** @var bool */
35    protected bool $wasPosted;
36
37    /**
38     * @param WANObjectCache $wanCache
39     * @param bool $wasPosted
40     */
41    public function __construct(
42        WANObjectCache $wanCache,
43        bool $wasPosted
44    ) {
45        $this->wanCache = $wanCache;
46        $this->wasPosted = $wasPosted;
47
48        $this->setLogger( new NullLogger() );
49    }
50
51    /**
52     * Helper to generate cache key for a mentee
53     * @param UserIdentity $user Mentee's username
54     * @param string $mentorRole
55     * @return string Cache key
56     */
57    protected function makeLoadMentorCacheKey(
58        UserIdentity $user,
59        string $mentorRole
60    ): string {
61        return $this->wanCache->makeKey(
62            'GrowthExperiments',
63            'MentorStore', __CLASS__,
64            'Mentee', $user->getId(),
65            'Mentor', $mentorRole
66        );
67    }
68
69    /**
70     * Invalidates mentor cache for loadMentorUser
71     * @param UserIdentity $user Who will have their cache invalidated
72     * @param string $mentorRole
73     */
74    protected function invalidateMentorCache( UserIdentity $user, string $mentorRole ): void {
75        $key = $this->makeLoadMentorCacheKey( $user, $mentorRole );
76        $this->wanCache->delete(
77            $key
78        );
79        unset( $this->inProcessCache[$key] );
80    }
81
82    /**
83     * Code helper for validating mentor type
84     *
85     * @param string $mentorRole
86     * @return bool True when valid, false otherwise
87     */
88    private function validateMentorRole( string $mentorRole ): bool {
89        return in_array( $mentorRole, self::ROLES );
90    }
91
92    /**
93     * Get the mentor assigned to this user, if it exists.
94     * @param UserIdentity $mentee
95     * @param string $mentorRole One of MentorStore::ROLE_* constants
96     * @param int $flags bit field, see IDBAccessObject::READ_XXX
97     * @return UserIdentity|null
98     */
99    public function loadMentorUser(
100        UserIdentity $mentee,
101        string $mentorRole,
102        $flags = IDBAccessObject::READ_NORMAL
103    ): ?UserIdentity {
104        if ( !$this->validateMentorRole( $mentorRole ) ) {
105            throw new InvalidArgumentException( "Invalid \$mentorRole passed: $mentorRole" );
106        }
107
108        if ( DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ) {
109            $this->invalidateMentorCache( $mentee, $mentorRole );
110        }
111
112        $cacheKey = $this->makeLoadMentorCacheKey( $mentee, $mentorRole );
113        if ( isset( $this->inProcessCache[$cacheKey] ) ) {
114            return $this->inProcessCache[$cacheKey];
115        }
116
117        $res = $this->wanCache->getWithSetCallback(
118            $cacheKey,
119            self::TTL_DAY,
120            function () use ( $mentee, $mentorRole, $flags ) {
121                return $this->loadMentorUserUncached( $mentee, $mentorRole, $flags );
122            }
123        );
124        $this->inProcessCache[$cacheKey] = $res;
125        return $res;
126    }
127
128    /**
129     * Load mentor user with no cache
130     *
131     * @param UserIdentity $mentee
132     * @param string $mentorRole One of MentorStore::ROLE_* constants
133     * @param int $flags bit field, see IDBAccessObject::READ_XXX
134     * @return UserIdentity|null
135     */
136    abstract protected function loadMentorUserUncached(
137        UserIdentity $mentee,
138        string $mentorRole,
139        $flags
140    ): ?UserIdentity;
141
142    /**
143     * Return mentees who are mentored by given mentor
144     *
145     * @param UserIdentity $mentor
146     * @param string $mentorRole
147     * @param bool $includeHiddenUsers
148     * @param bool $includeInactiveUsers
149     * @param int $flags
150     * @return UserIdentity[]
151     */
152    abstract public function getMenteesByMentor(
153        UserIdentity $mentor,
154        string $mentorRole,
155        bool $includeHiddenUsers = false,
156        bool $includeInactiveUsers = true,
157        int $flags = 0
158    ): array;
159
160    /**
161     * Checks whether a mentor has any mentees assigned
162     *
163     * @param UserIdentity $mentor
164     * @param string $mentorRole
165     * @param bool $includeHiddenUsers
166     * @param int $flags
167     * @return bool
168     */
169    public function hasAnyMentees(
170        UserIdentity $mentor,
171        string $mentorRole,
172        bool $includeHiddenUsers = false,
173        int $flags = 0
174    ): bool {
175        return $this->getMenteesByMentor(
176            $mentor, $mentorRole, $includeHiddenUsers, true, $flags
177        ) !== [];
178    }
179
180    /**
181     * Assign a mentor to this user, overriding any previous assignments.
182     *
183     * This method can be safely called on GET requests.
184     *
185     * The actual logic for changing mentor is in setMentorForUserInternal, this method
186     * only validates mentor type and calls the internal one.
187     *
188     * @param UserIdentity $mentee
189     * @param UserIdentity|null $mentor Null to drop the relationship
190     * @param string $mentorRole One of MentorStore::ROLE_* constants
191     */
192    public function setMentorForUser(
193        UserIdentity $mentee,
194        ?UserIdentity $mentor,
195        string $mentorRole
196    ): void {
197        if ( !$this->validateMentorRole( $mentorRole ) ) {
198            throw new InvalidArgumentException( "Invalid \$mentorRole passed: $mentorRole" );
199        }
200
201        $this->setMentorForUserInternal( $mentee, $mentor, $mentorRole );
202
203        $this->invalidateMentorCache( $mentee, $mentorRole );
204        $this->invalidateIsMenteeActive( $mentee );
205
206        // Set the mentor in the in-process cache
207        $this->inProcessCache[$this->makeLoadMentorCacheKey( $mentee, $mentorRole )] = $mentor;
208    }
209
210    /**
211     * Actual logic for setting a mentor
212     * @param UserIdentity $mentee
213     * @param UserIdentity|null $mentor Set to null to drop the relationship
214     * @param string $mentorRole
215     */
216    abstract protected function setMentorForUserInternal(
217        UserIdentity $mentee,
218        ?UserIdentity $mentor,
219        string $mentorRole
220    ): void;
221
222    /**
223     * Drop mentor/mentee relationship for a given user
224     *
225     * @param UserIdentity $mentee
226     */
227    public function dropMenteeRelationship( UserIdentity $mentee ): void {
228        foreach ( self::ROLES as $role ) {
229            $this->setMentorForUser( $mentee, null, $role );
230        }
231    }
232
233    /**
234     * Is an user considered a mentee?
235     *
236     * Equivalent to "Do they have a primary mentor assigned?"
237     *
238     * @param UserIdentity $user
239     * @param int $flags
240     * @return bool
241     */
242    public function isMentee(
243        UserIdentity $user,
244        int $flags = IDBAccessObject::READ_NORMAL
245    ): bool {
246        return $this->loadMentorUser(
247            $user,
248            self::ROLE_PRIMARY,
249            $flags
250        ) !== null;
251    }
252
253    /**
254     * Make cache key for isMenteeActive()
255     *
256     * @param UserIdentity $user
257     * @return string
258     */
259    private function makeIsMenteeActiveCacheKey( UserIdentity $user ): string {
260        return $this->wanCache->makeKey(
261            'GrowthExperiments',
262            'MentorStore', __CLASS__,
263            'Mentee', $user->getId(),
264            'IsActive'
265        );
266    }
267
268    /**
269     * Invalidates cache for isMenteeActive()
270     *
271     * @param UserIdentity $user
272     */
273    protected function invalidateIsMenteeActive( UserIdentity $user ): void {
274        $this->wanCache->delete( $this->makeIsMenteeActiveCacheKey( $user ) );
275    }
276
277    /**
278     * Is the mentee active?
279     *
280     * This will be used by MentorFilterHooks to only include
281     * recently active mentees, to avoid errors like T293182.
282     *
283     * A mentee should be marked as active if they edited less than
284     * $wgRCMaxAge seconds ago.
285     *
286     * @param UserIdentity $mentee
287     * @return bool|null
288     */
289    public function isMenteeActive( UserIdentity $mentee ): ?bool {
290        return $this->wanCache->getWithSetCallback(
291            $this->makeIsMenteeActiveCacheKey( $mentee ),
292            self::TTL_DAY,
293            function () use ( $mentee ) {
294                return $this->isMenteeActiveUncached( $mentee );
295            }
296        );
297    }
298
299    /**
300     * Is the mentee active?
301     *
302     * Bypasses caching.
303     *
304     * @see MentorStore::isMenteeActive()
305     * @param UserIdentity $mentee
306     * @return bool|null
307     */
308    abstract protected function isMenteeActiveUncached( UserIdentity $mentee ): ?bool;
309
310    /**
311     * Mark the mentee as active
312     *
313     * This will be used by MentorFilterHooks to only include
314     * recently active mentees, to avoid errors like T293182.
315     *
316     * A mentee should be marked as active if they edited less than
317     * $wgRCMaxAge seconds ago.
318     *
319     * Method should only make a write query if the mentee is not
320     * already marked as active.
321     *
322     * @param UserIdentity $mentee
323     */
324    abstract public function markMenteeAsActive( UserIdentity $mentee ): void;
325
326    /**
327     * Mark a mentee as inactive
328     *
329     * This will be used by MentorFilterHooks to only include
330     * recently active mentees, to avoid errors like T293182.
331     *
332     * A mentee should be marked as inactive if they edited more than
333     * $wgRCMaxAge seconds ago.
334     *
335     * Method should only make a write query if the mentee is not
336     * already marked as inactive.
337     *
338     * @param UserIdentity $mentee
339     */
340    abstract public function markMenteeAsInactive( UserIdentity $mentee ): void;
341}