Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.74% covered (danger)
49.74%
95 / 191
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationMapper
50.00% covered (danger)
50.00%
95 / 190
18.18% covered (danger)
18.18%
2 / 11
188.12
0.00% covered (danger)
0.00%
0 / 1
 insert
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 extractQueryOffset
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
3.85
 fetchUnreadByUser
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 fetchReadByUser
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 fetchByUser
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
4.30
 getIdsForTitles
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 fetchByUserInternal
77.78% covered (warning)
77.78%
28 / 36
0.00% covered (danger)
0.00%
0 / 1
8.70
 fetchByUserEvents
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 fetchByUserOffset
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 deleteByUserEventOffset
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
3
 fetchUsersWithNotificationsForEvents
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Notifications\Mapper;
4
5use BatchRowIterator;
6use Exception;
7use InvalidArgumentException;
8use MediaWiki\Deferred\AtomicSectionUpdate;
9use MediaWiki\Deferred\DeferredUpdates;
10use MediaWiki\Extension\Notifications\Model\Notification;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Title\Title;
13use MediaWiki\User\UserIdentity;
14use MWExceptionHandler;
15use Wikimedia\Rdbms\IDatabase;
16use Wikimedia\Rdbms\SelectQueryBuilder;
17
18/**
19 * Database mapper for Notification model
20 */
21class 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
419class_alias( NotificationMapper::class, 'EchoNotificationMapper' );