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