Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
49.74% |
95 / 191 |
|
18.18% |
2 / 11 |
CRAP | |
0.00% |
0 / 1 |
NotificationMapper | |
50.00% |
95 / 190 |
|
18.18% |
2 / 11 |
188.12 | |
0.00% |
0 / 1 |
insert | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
6 | |||
extractQueryOffset | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
3.85 | |||
fetchUnreadByUser | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
fetchReadByUser | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
fetchByUser | |
73.33% |
11 / 15 |
|
0.00% |
0 / 1 |
4.30 | |||
getIdsForTitles | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
fetchByUserInternal | |
77.78% |
28 / 36 |
|
0.00% |
0 / 1 |
8.70 | |||
fetchByUserEvents | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
fetchByUserOffset | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
deleteByUserEventOffset | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
3 | |||
fetchUsersWithNotificationsForEvents | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Notifications\Mapper; |
4 | |
5 | use BatchRowIterator; |
6 | use Exception; |
7 | use InvalidArgumentException; |
8 | use MediaWiki\Deferred\AtomicSectionUpdate; |
9 | use MediaWiki\Deferred\DeferredUpdates; |
10 | use MediaWiki\Extension\Notifications\Model\Notification; |
11 | use MediaWiki\MediaWikiServices; |
12 | use MediaWiki\Title\Title; |
13 | use MediaWiki\User\UserIdentity; |
14 | use MWExceptionHandler; |
15 | use Wikimedia\Rdbms\IDatabase; |
16 | use Wikimedia\Rdbms\SelectQueryBuilder; |
17 | |
18 | /** |
19 | * Database mapper for Notification model |
20 | */ |
21 | class NotificationMapper extends AbstractMapper { |
22 | |
23 | /** |
24 | * Insert a notification record |
25 | * @param Notification $notification |
26 | */ |
27 | public function insert( Notification $notification ) { |
28 | $dbw = $this->dbFactory->getEchoDb( DB_PRIMARY ); |
29 | |
30 | $listeners = $this->getMethodListeners( __FUNCTION__ ); |
31 | |
32 | $row = $notification->toDbArray(); |
33 | DeferredUpdates::addUpdate( new AtomicSectionUpdate( |
34 | $dbw, |
35 | __METHOD__, |
36 | static function ( IDatabase $dbw, $fname ) use ( $row, $listeners ) { |
37 | $row['notification_timestamp'] = |
38 | $dbw->timestamp( $row['notification_timestamp'] ); |
39 | $row['notification_read_timestamp'] = |
40 | $dbw->timestampOrNull( $row['notification_read_timestamp'] ); |
41 | $dbw->newInsertQueryBuilder() |
42 | ->insertInto( 'echo_notification' ) |
43 | ->row( $row ) |
44 | ->caller( $fname ) |
45 | ->execute(); |
46 | foreach ( $listeners as $listener ) { |
47 | $dbw->onTransactionCommitOrIdle( $listener, $fname ); |
48 | } |
49 | } |
50 | ) ); |
51 | } |
52 | |
53 | /** |
54 | * Extract the offset used for notification list |
55 | * @param string|null $continue String Used for offset |
56 | * @return int[] |
57 | */ |
58 | protected function extractQueryOffset( $continue ) { |
59 | $offset = [ |
60 | 'timestamp' => 0, |
61 | 'offset' => 0, |
62 | ]; |
63 | if ( $continue ) { |
64 | $values = explode( '|', $continue, 3 ); |
65 | if ( count( $values ) !== 2 ) { |
66 | throw new InvalidArgumentException( 'Invalid continue param: ' . $continue ); |
67 | } |
68 | $offset['timestamp'] = (int)$values[0]; |
69 | $offset['offset'] = (int)$values[1]; |
70 | } |
71 | |
72 | return $offset; |
73 | } |
74 | |
75 | /** |
76 | * Get unread notifications by user in the amount specified by limit order by |
77 | * notification timestamp in descending order. We have an index to retrieve |
78 | * unread notifications, but it's not optimized for ordering by timestamp. The |
79 | * descending order is only allowed if we keep the notification in low volume, |
80 | * which is done via a deleteJob |
81 | * @param UserIdentity $userIdentity |
82 | * @param int $limit |
83 | * @param string|null $continue Used for offset |
84 | * @param string[] $eventTypes |
85 | * @param Title[]|null $titles If set, only return notifications for these pages. |
86 | * To find notifications not associated with any page, add null as an element to this array. |
87 | * @param int $dbSource Use primary database or replica database |
88 | * @return Notification[] |
89 | */ |
90 | public function fetchUnreadByUser( |
91 | UserIdentity $userIdentity, |
92 | $limit, |
93 | $continue, |
94 | array $eventTypes = [], |
95 | array $titles = null, |
96 | $dbSource = DB_REPLICA |
97 | ) { |
98 | $conds = [ 'notification_read_timestamp' => null ]; |
99 | if ( $titles ) { |
100 | $conds['event_page_id'] = $this->getIdsForTitles( $titles ); |
101 | if ( !$conds['event_page_id'] ) { |
102 | return []; |
103 | } |
104 | } |
105 | return $this->fetchByUserInternal( |
106 | $userIdentity, |
107 | $limit, |
108 | $continue, |
109 | $eventTypes, |
110 | $conds, |
111 | $dbSource |
112 | ); |
113 | } |
114 | |
115 | /** |
116 | * Get read notifications by user in the amount specified by limit order by |
117 | * notification timestamp in descending order. We have an index to retrieve |
118 | * unread notifications but it's not optimized for ordering by timestamp. The |
119 | * descending order is only allowed if we keep the notification in low volume, |
120 | * which is done via a deleteJob |
121 | * @param UserIdentity $userIdentity |
122 | * @param int $limit |
123 | * @param string|null $continue Used for offset |
124 | * @param string[] $eventTypes |
125 | * @param Title[]|null $titles If set, only return notifications for these pages. |
126 | * To find notifications not associated with any page, add null as an element to this array. |
127 | * @param int $dbSource Use primary database or replica database |
128 | * @return Notification[] |
129 | */ |
130 | public function fetchReadByUser( |
131 | UserIdentity $userIdentity, |
132 | $limit, |
133 | $continue, |
134 | array $eventTypes = [], |
135 | array $titles = null, |
136 | $dbSource = DB_REPLICA |
137 | ) { |
138 | $conds = [ 'notification_read_timestamp IS NOT NULL' ]; |
139 | if ( $titles ) { |
140 | $conds['event_page_id'] = $this->getIdsForTitles( $titles ); |
141 | if ( !$conds['event_page_id'] ) { |
142 | return []; |
143 | } |
144 | } |
145 | return $this->fetchByUserInternal( |
146 | $userIdentity, |
147 | $limit, |
148 | $continue, |
149 | $eventTypes, |
150 | $conds, |
151 | $dbSource |
152 | ); |
153 | } |
154 | |
155 | /** |
156 | * Get Notification by user in batch along with limit, offset etc |
157 | * |
158 | * @param UserIdentity $userIdentity the user to get notifications for |
159 | * @param int $limit The maximum number of notifications to return |
160 | * @param string|null $continue Used for offset |
161 | * @param array $eventTypes Event types to load |
162 | * @param array $excludeEventIds Event id's to exclude. |
163 | * @param Title[]|null $titles If set, only return notifications for these pages. |
164 | * To find notifications not associated with any page, add null as an element to this array. |
165 | * @return Notification[] |
166 | */ |
167 | public function fetchByUser( |
168 | UserIdentity $userIdentity, |
169 | $limit, |
170 | $continue, |
171 | array $eventTypes = [], |
172 | array $excludeEventIds = [], |
173 | array $titles = null |
174 | ) { |
175 | $dbr = $this->dbFactory->getEchoDb( DB_REPLICA ); |
176 | |
177 | $conds = []; |
178 | if ( $excludeEventIds ) { |
179 | $conds[] = $dbr->expr( 'event_id', '!=', $excludeEventIds ); |
180 | } |
181 | if ( $titles ) { |
182 | $conds['event_page_id'] = $this->getIdsForTitles( $titles ); |
183 | if ( !$conds['event_page_id'] ) { |
184 | return []; |
185 | } |
186 | } |
187 | |
188 | return $this->fetchByUserInternal( |
189 | $userIdentity, |
190 | $limit, |
191 | $continue, |
192 | $eventTypes, |
193 | $conds |
194 | ); |
195 | } |
196 | |
197 | protected function getIdsForTitles( array $titles ) { |
198 | $ids = []; |
199 | foreach ( $titles as $title ) { |
200 | if ( $title === null ) { |
201 | $ids[] = null; |
202 | } elseif ( $title->exists() ) { |
203 | $ids[] = $title->getArticleId(); |
204 | } |
205 | } |
206 | return $ids; |
207 | } |
208 | |
209 | /** |
210 | * @param UserIdentity $userIdentity the user to get notifications for |
211 | * @param int $limit The maximum number of notifications to return |
212 | * @param string|null $continue Used for offset |
213 | * @param array $eventTypes Event types to load |
214 | * @param array $conds Additional query conditions. |
215 | * @param int $dbSource Use primary database or replica database |
216 | * @return Notification[] |
217 | */ |
218 | protected function fetchByUserInternal( |
219 | UserIdentity $userIdentity, |
220 | $limit, |
221 | $continue, |
222 | array $eventTypes = [], |
223 | array $conds = [], |
224 | $dbSource = DB_REPLICA |
225 | ) { |
226 | $dbr = $this->dbFactory->getEchoDb( $dbSource ); |
227 | |
228 | if ( !$eventTypes ) { |
229 | return []; |
230 | } |
231 | |
232 | // There is a problem with querying by event type, if a user has only one or none |
233 | // flow notification and huge amount other notifications, the lookup of only flow |
234 | // notification will result in a slow query. Luckily users won't have that many |
235 | // notifications. We should have some cron job to remove old notifications so |
236 | // the notification volume is in a reasonable amount for such case. The other option |
237 | // is to denormalize notification table with event_type and lookup index. |
238 | $conds = [ |
239 | 'notification_user' => $userIdentity->getId(), |
240 | 'event_type' => $eventTypes, |
241 | 'event_deleted' => 0, |
242 | ] + $conds; |
243 | |
244 | $offset = $this->extractQueryOffset( $continue ); |
245 | |
246 | // Start points are specified |
247 | if ( $offset['timestamp'] && $offset['offset'] ) { |
248 | // The offset and timestamp are those of the first notification we want to return |
249 | $conds[] = $dbr->buildComparison( '<=', [ |
250 | 'notification_timestamp' => $dbr->timestamp( $offset['timestamp'] ), |
251 | 'notification_event' => $offset['offset'], |
252 | ] ); |
253 | } |
254 | |
255 | $res = $dbr->newSelectQueryBuilder() |
256 | ->select( Notification::selectFields() ) |
257 | ->from( 'echo_notification' ) |
258 | ->leftJoin( 'echo_event', null, 'notification_event=event_id' ) |
259 | ->where( $conds ) |
260 | ->orderBy( [ 'notification_timestamp', 'notification_event' ], SelectQueryBuilder::SORT_DESC ) |
261 | ->limit( $limit ) |
262 | ->caller( __METHOD__ ) |
263 | ->fetchResultSet(); |
264 | |
265 | /** @var Notification[] $allNotifications */ |
266 | $allNotifications = []; |
267 | foreach ( $res as $row ) { |
268 | try { |
269 | $notification = Notification::newFromRow( $row ); |
270 | if ( $notification ) { |
271 | $allNotifications[] = $notification; |
272 | } |
273 | } catch ( Exception $e ) { |
274 | $id = $row->event_id ?? 'unknown event'; |
275 | wfDebugLog( 'Echo', __METHOD__ . ": Failed initializing event: $id" ); |
276 | MWExceptionHandler::logException( $e ); |
277 | } |
278 | } |
279 | |
280 | $data = []; |
281 | foreach ( $allNotifications as $notification ) { |
282 | $data[ $notification->getEvent()->getId() ] = $notification; |
283 | } |
284 | |
285 | return $data; |
286 | } |
287 | |
288 | /** |
289 | * Fetch Notifications by user and event IDs. |
290 | * |
291 | * @param UserIdentity $userIdentity |
292 | * @param int[] $eventIds |
293 | * @return Notification[] |
294 | */ |
295 | public function fetchByUserEvents( UserIdentity $userIdentity, array $eventIds ) { |
296 | $dbr = $this->dbFactory->getEchoDb( DB_REPLICA ); |
297 | |
298 | $result = $dbr->newSelectQueryBuilder() |
299 | ->select( Notification::selectFields() ) |
300 | ->from( 'echo_notification' ) |
301 | ->join( 'echo_event', null, 'notification_event=event_id' ) |
302 | ->where( [ |
303 | 'notification_user' => $userIdentity->getId(), |
304 | 'notification_event' => $eventIds |
305 | ] ) |
306 | ->caller( __METHOD__ ) |
307 | ->fetchResultSet(); |
308 | |
309 | $notifications = []; |
310 | foreach ( $result as $row ) { |
311 | $notifications[] = Notification::newFromRow( $row ); |
312 | } |
313 | return $notifications; |
314 | } |
315 | |
316 | /** |
317 | * Fetch a notification by user in the specified offset. The caller should |
318 | * know that passing a big number for offset is NOT going to work |
319 | * @param UserIdentity $userIdentity |
320 | * @param int $offset |
321 | * @return Notification|false |
322 | */ |
323 | public function fetchByUserOffset( UserIdentity $userIdentity, $offset ) { |
324 | $dbr = $this->dbFactory->getEchoDb( DB_REPLICA ); |
325 | $row = $dbr->newSelectQueryBuilder() |
326 | ->select( Notification::selectFields() ) |
327 | ->from( 'echo_notification' ) |
328 | ->leftJoin( 'echo_event', null, 'notification_event=event_id' ) |
329 | ->where( [ |
330 | 'notification_user' => $userIdentity->getId(), |
331 | 'event_deleted' => 0, |
332 | ] ) |
333 | ->orderBy( [ 'notification_timestamp', 'notification_event' ], SelectQueryBuilder::SORT_DESC ) |
334 | ->offset( $offset ) |
335 | ->caller( __METHOD__ ) |
336 | ->fetchRow(); |
337 | |
338 | if ( $row ) { |
339 | return Notification::newFromRow( $row ); |
340 | } else { |
341 | return false; |
342 | } |
343 | } |
344 | |
345 | /** |
346 | * Batch delete notifications by user and eventId offset |
347 | * @param UserIdentity $userIdentity |
348 | * @param int $eventId |
349 | * @return bool |
350 | */ |
351 | public function deleteByUserEventOffset( UserIdentity $userIdentity, $eventId ) { |
352 | global $wgUpdateRowsPerQuery; |
353 | $eventMapper = new EventMapper( $this->dbFactory ); |
354 | $userId = $userIdentity->getId(); |
355 | $dbw = $this->dbFactory->getEchoDb( DB_PRIMARY ); |
356 | $dbr = $this->dbFactory->getEchoDb( DB_REPLICA ); |
357 | $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); |
358 | $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
359 | $domainId = $dbw->getDomainID(); |
360 | |
361 | $iterator = new BatchRowIterator( |
362 | $dbr, |
363 | 'echo_notification', |
364 | 'notification_event', |
365 | $wgUpdateRowsPerQuery |
366 | ); |
367 | $iterator->addConditions( [ |
368 | 'notification_user' => $userId, |
369 | 'notification_event < ' . (int)$eventId |
370 | ] ); |
371 | $iterator->setCaller( __METHOD__ ); |
372 | |
373 | foreach ( $iterator as $batch ) { |
374 | $eventIds = []; |
375 | foreach ( $batch as $row ) { |
376 | $eventIds[] = $row->notification_event; |
377 | } |
378 | $dbw->newDeleteQueryBuilder() |
379 | ->deleteFrom( 'echo_notification' ) |
380 | ->where( [ |
381 | 'notification_user' => $userId, |
382 | 'notification_event' => $eventIds, |
383 | ] ) |
384 | ->caller( __METHOD__ ) |
385 | ->execute(); |
386 | |
387 | // Find out which events are now orphaned, i.e. no longer referenced in echo_notifications |
388 | // (besides the rows we just deleted) or in echo_email_batch, and delete them |
389 | $eventMapper->deleteOrphanedEvents( $eventIds, $userId, 'echo_notification' ); |
390 | |
391 | $lbFactory->commitAndWaitForReplication( |
392 | __METHOD__, $ticket, [ 'domain' => $domainId ] ); |
393 | } |
394 | return true; |
395 | } |
396 | |
397 | /** |
398 | * Fetch ids of users that have notifications for certain events |
399 | * |
400 | * @param int[] $eventIds |
401 | * @return int[] |
402 | */ |
403 | public function fetchUsersWithNotificationsForEvents( array $eventIds ) { |
404 | $dbr = $this->dbFactory->getEchoDb( DB_REPLICA ); |
405 | |
406 | return $dbr->newSelectQueryBuilder() |
407 | ->select( 'notification_user' ) |
408 | ->distinct() |
409 | ->from( 'echo_notification' ) |
410 | ->where( [ |
411 | 'notification_event' => $eventIds |
412 | ] ) |
413 | ->caller( __METHOD__ ) |
414 | ->fetchFieldValues(); |
415 | } |
416 | |
417 | } |
418 | |
419 | class_alias( NotificationMapper::class, 'EchoNotificationMapper' ); |