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