Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubscriptionStore
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 10
812
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 fetchSubscriptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getSubscriptionItemsForUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 getSubscriptionItemsForTopic
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getSubscriptionItemFromRow
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 addSubscriptionForUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 removeSubscriptionForUser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 addAutoSubscriptionForUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 updateSubscriptionTimestamp
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 updateSubscriptionNotifiedTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Config;
6use ConfigFactory;
7use MediaWiki\Linker\LinkTarget;
8use MediaWiki\User\UserFactory;
9use MediaWiki\User\UserIdentity;
10use ReadOnlyMode;
11use stdClass;
12use TitleValue;
13use Wikimedia\Rdbms\FakeResultWrapper;
14use Wikimedia\Rdbms\IDatabase;
15use Wikimedia\Rdbms\ILBFactory;
16use Wikimedia\Rdbms\ILoadBalancer;
17use Wikimedia\Rdbms\IResultWrapper;
18
19class SubscriptionStore {
20
21    /**
22     * Constants for the values of the sub_state field.
23     */
24    public const STATE_UNSUBSCRIBED = 0;
25    public const STATE_SUBSCRIBED = 1;
26    public const STATE_AUTOSUBSCRIBED = 2;
27
28    private Config $config;
29    private ILoadBalancer $loadBalancer;
30    private ReadOnlyMode $readOnlyMode;
31    private UserFactory $userFactory;
32
33    public function __construct(
34        ConfigFactory $configFactory,
35        ILBFactory $lbFactory,
36        ReadOnlyMode $readOnlyMode,
37        UserFactory $userFactory
38    ) {
39        $this->config = $configFactory->makeConfig( 'discussiontools' );
40        $this->loadBalancer = $lbFactory->getMainLB();
41        $this->readOnlyMode = $readOnlyMode;
42        $this->userFactory = $userFactory;
43    }
44
45    /**
46     * @param IDatabase $db
47     * @param UserIdentity|null $user
48     * @param array|null $itemNames
49     * @param int|int[]|null $state One of (or an array of) SubscriptionStore::STATE_* constants
50     * @return IResultWrapper|false
51     */
52    private function fetchSubscriptions(
53        IDatabase $db,
54        ?UserIdentity $user = null,
55        ?array $itemNames = null,
56        $state = null
57    ) {
58        $conditions = [];
59
60        if ( $user ) {
61            $conditions[ 'sub_user' ] = $user->getId();
62        }
63
64        if ( $itemNames !== null ) {
65            if ( !count( $itemNames ) ) {
66                // We are not allowed to construct a filter with an empty array.
67                // Any empty array should result in no items being returned.
68                return new FakeResultWrapper( [] );
69            }
70            $conditions[ 'sub_item' ] = $itemNames;
71        }
72
73        if ( $state !== null ) {
74            $conditions[ 'sub_state' ] = $state;
75        }
76
77        return $db->newSelectQueryBuilder()
78            ->from( 'discussiontools_subscription' )
79            ->fields( [
80                'sub_user', 'sub_item', 'sub_namespace', 'sub_title', 'sub_section', 'sub_state',
81                'sub_created', 'sub_notified'
82            ] )
83            ->where( $conditions )
84            ->caller( __METHOD__ )
85            ->fetchResultSet();
86    }
87
88    /**
89     * @param UserIdentity $user
90     * @param array|null $itemNames
91     * @param int[]|null $state Array of SubscriptionStore::STATE_* constants
92     * @param array $options
93     * @return SubscriptionItem[]
94     */
95    public function getSubscriptionItemsForUser(
96        UserIdentity $user,
97        ?array $itemNames = null,
98        ?array $state = null,
99        array $options = []
100    ): array {
101        // Only a registered user can be subscribed
102        if ( !$user->isRegistered() ) {
103            return [];
104        }
105
106        $options += [ 'forWrite' => false ];
107        $db = $this->loadBalancer->getConnection( $options['forWrite'] ? DB_PRIMARY : DB_REPLICA );
108
109        $rows = $this->fetchSubscriptions(
110            $db,
111            $user,
112            $itemNames,
113            $state
114        );
115
116        if ( !$rows ) {
117            return [];
118        }
119
120        $items = [];
121        foreach ( $rows as $row ) {
122            $target = new TitleValue( (int)$row->sub_namespace, $row->sub_title, $row->sub_section );
123            $items[] = $this->getSubscriptionItemFromRow( $user, $target, $row );
124        }
125
126        return $items;
127    }
128
129    /**
130     * @param string $itemName
131     * @param int[]|null $state An array of SubscriptionStore::STATE_* constants
132     * @param array $options
133     * @return array
134     */
135    public function getSubscriptionItemsForTopic(
136        string $itemName,
137        ?array $state = null,
138        array $options = []
139    ): array {
140        $options += [ 'forWrite' => false ];
141        $db = $this->loadBalancer->getConnection( $options['forWrite'] ? DB_PRIMARY : DB_REPLICA );
142
143        $rows = $this->fetchSubscriptions(
144            $db,
145            null,
146            [ $itemName ],
147            $state
148        );
149
150        if ( !$rows ) {
151            return [];
152        }
153
154        $items = [];
155        foreach ( $rows as $row ) {
156            $target = new TitleValue( (int)$row->sub_namespace, $row->sub_title, $row->sub_section );
157            $user = $this->userFactory->newFromId( $row->sub_user );
158            $items[] = $this->getSubscriptionItemFromRow( $user, $target, $row );
159        }
160
161        return $items;
162    }
163
164    /**
165     * @param UserIdentity $user
166     * @param LinkTarget $target
167     * @param stdClass $row
168     * @return SubscriptionItem
169     */
170    private function getSubscriptionItemFromRow(
171        UserIdentity $user,
172        LinkTarget $target,
173        stdClass $row
174    ): SubscriptionItem {
175        return new SubscriptionItem(
176            $user,
177            $row->sub_item,
178            $target,
179            $row->sub_state,
180            $row->sub_created,
181            $row->sub_notified
182        );
183    }
184
185    /**
186     * @param UserIdentity $user
187     * @param LinkTarget $target
188     * @param string $itemName
189     * @param int $state
190     * @return bool
191     */
192    public function addSubscriptionForUser(
193        UserIdentity $user,
194        LinkTarget $target,
195        string $itemName,
196        // Can't use static:: in compile-time constants
197        int $state = self::STATE_SUBSCRIBED
198    ): bool {
199        if ( $this->readOnlyMode->isReadOnly() ) {
200            return false;
201        }
202        // Only a registered user can subscribe
203        if ( !$user->isRegistered() ) {
204            return false;
205        }
206        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
207        $dbw->upsert(
208            'discussiontools_subscription',
209            [
210                'sub_user' => $user->getId(),
211                'sub_namespace' => $target->getNamespace(),
212                'sub_title' => $target->getDBkey(),
213                'sub_section' => $target->getFragment(),
214                'sub_item' => $itemName,
215                'sub_state' => $state,
216                'sub_created' => $dbw->timestamp(),
217            ],
218            [ [ 'sub_user', 'sub_item' ] ],
219            [
220                'sub_state' => $state,
221            ],
222            __METHOD__
223        );
224        return (bool)$dbw->affectedRows();
225    }
226
227    /**
228     * @param UserIdentity $user
229     * @param string $itemName
230     * @return bool
231     */
232    public function removeSubscriptionForUser(
233        UserIdentity $user,
234        string $itemName
235    ): bool {
236        if ( $this->readOnlyMode->isReadOnly() ) {
237            return false;
238        }
239        // Only a registered user can subscribe
240        if ( !$user->isRegistered() ) {
241            return false;
242        }
243        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
244        $dbw->update(
245            'discussiontools_subscription',
246            [ 'sub_state' => static::STATE_UNSUBSCRIBED ],
247            [
248                'sub_user' => $user->getId(),
249                'sub_item' => $itemName,
250            ],
251            __METHOD__
252        );
253        return (bool)$dbw->affectedRows();
254    }
255
256    /**
257     * @param UserIdentity $user
258     * @param LinkTarget $target
259     * @param string $itemName
260     * @return bool
261     */
262    public function addAutoSubscriptionForUser(
263        UserIdentity $user,
264        LinkTarget $target,
265        string $itemName
266    ): bool {
267        // Check for existing subscriptions.
268        $subscriptionItems = $this->getSubscriptionItemsForUser(
269            $user,
270            [ $itemName ],
271            [ static::STATE_SUBSCRIBED, static::STATE_AUTOSUBSCRIBED ],
272            [ 'forWrite' => true ]
273        );
274        if ( $subscriptionItems ) {
275            return false;
276        }
277
278        return $this->addSubscriptionForUser(
279            $user,
280            $target,
281            $itemName,
282            static::STATE_AUTOSUBSCRIBED
283        );
284    }
285
286    /**
287     * @param string $field Timestamp field name
288     * @param UserIdentity|null $user
289     * @param string $itemName
290     * @return bool
291     */
292    private function updateSubscriptionTimestamp(
293        string $field,
294        ?UserIdentity $user,
295        string $itemName
296    ): bool {
297        if ( $this->readOnlyMode->isReadOnly() ) {
298            return false;
299        }
300        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
301
302        $conditions = [
303            'sub_item' => $itemName,
304        ];
305
306        if ( $user ) {
307            $conditions[ 'sub_user' ] = $user->getId();
308        }
309
310        $dbw->update(
311            'discussiontools_subscription',
312            [ $field => $dbw->timestamp() ],
313            $conditions,
314            __METHOD__
315        );
316        return (bool)$dbw->affectedRows();
317    }
318
319    /**
320     * Update the notified timestamp on a subscription
321     *
322     * This field could be used in future to cleanup notifications
323     * that are no longer needed (e.g. because the conversation has
324     * been archived), so should be set for muted notifications too.
325     *
326     * @param UserIdentity|null $user
327     * @param string $itemName
328     * @return bool
329     */
330    public function updateSubscriptionNotifiedTimestamp(
331        ?UserIdentity $user,
332        string $itemName
333    ): bool {
334        return $this->updateSubscriptionTimestamp(
335            'sub_notified',
336            $user,
337            $itemName
338        );
339    }
340}