Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
87.48% |
566 / 647 |
|
40.00% |
12 / 30 |
CRAP | |
0.00% |
0 / 1 |
ReadingListRepository | |
87.48% |
566 / 647 |
|
40.00% |
12 / 30 |
143.86 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
setLimits | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setupForUser | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
teardownForUser | |
80.00% |
16 / 20 |
|
0.00% |
0 / 1 |
3.07 | |||
isSetupForUser | |
82.61% |
19 / 23 |
|
0.00% |
0 / 1 |
3.05 | |||
selectValidList | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
addList | |
89.83% |
53 / 59 |
|
0.00% |
0 / 1 |
7.05 | |||
getAllLists | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
updateList | |
69.57% |
32 / 46 |
|
0.00% |
0 / 1 |
11.28 | |||
deleteList | |
78.26% |
18 / 23 |
|
0.00% |
0 / 1 |
3.09 | |||
addListEntry | |
93.98% |
78 / 83 |
|
0.00% |
0 / 1 |
9.02 | |||
getListEntries | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
6 | |||
deleteListEntry | |
88.64% |
39 / 44 |
|
0.00% |
0 / 1 |
6.05 | |||
getListsByDateUpdated | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
getListEntriesByDateUpdated | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
purgeOldDeleted | |
100.00% |
55 / 55 |
|
100.00% |
1 / 1 |
7 | |||
getListsByPage | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
5 | |||
fixListSize | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
6 | |||
getListFields | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getListEntryFields | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
assertUser | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
assertFieldLength | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
processSort | |
91.18% |
31 / 34 |
|
0.00% |
0 / 1 |
18.22 | |||
getListCount | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
getProjectId | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
getEntryCount | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
initializeProjectIfNeeded | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
hasProjects | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getLocalProject | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\ReadingLists; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRow; |
7 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRowWithMergeFlag; |
8 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow; |
9 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListRowWithMergeFlag; |
10 | use MediaWiki\MediaWikiServices; |
11 | use Psr\Log\LoggerAwareInterface; |
12 | use Psr\Log\LoggerInterface; |
13 | use Psr\Log\NullLogger; |
14 | use Wikimedia\Rdbms\FakeResultWrapper; |
15 | use Wikimedia\Rdbms\IDatabase; |
16 | use Wikimedia\Rdbms\IDBAccessObject; |
17 | use Wikimedia\Rdbms\IReadableDatabase; |
18 | use Wikimedia\Rdbms\IResultWrapper; |
19 | use Wikimedia\Rdbms\LBFactory; |
20 | use Wikimedia\Rdbms\RawSQLValue; |
21 | |
22 | /** |
23 | * A DAO class for reading lists. |
24 | * |
25 | * Reading lists are private and only ever visible to the owner. The class is constructed with a |
26 | * user ID; unless otherwise noted, all operations are limited to lists/entries belonging to that |
27 | * user. Calling with parameters inconsistent with that will result in an error. |
28 | * |
29 | * Methods which query data will usually return a result set (as if Database::select was called |
30 | * directly). Methods which modify data don't return anything. A ReadingListRepositoryException |
31 | * will be thrown if the operation failed or was invalid. A lock on the list (for list entry |
32 | * changes) or on the default list (for list changes) is used to avoid race conditions, so most |
33 | * write commands can fail with lock timeouts as well. Since lists are private and conflict can |
34 | * only happen between devices of the same user, this should be exceedingly rare. |
35 | */ |
36 | class ReadingListRepository implements LoggerAwareInterface { |
37 | |
38 | /** Sort lists / entries alphabetically by name / title. */ |
39 | public const SORT_BY_NAME = 'name'; |
40 | /** Sort lists / entries chronologically by last updated timestamp. */ |
41 | public const SORT_BY_UPDATED = 'updated'; |
42 | /** Sort ascendingly (first letter / oldest date first). */ |
43 | public const SORT_DIR_ASC = 'asc'; |
44 | /** Sort descendingly (last letter / newest date first). */ |
45 | public const SORT_DIR_DESC = 'desc'; |
46 | |
47 | /** @var array Database field lengths in bytes (only for the string types). */ |
48 | public static $fieldLength = [ |
49 | 'rl_name' => 255, |
50 | 'rl_description' => 767, |
51 | 'rlp_project' => 255, |
52 | 'rle_title' => 383, |
53 | ]; |
54 | |
55 | /** @var int|null Max allowed lists per user */ |
56 | private $listLimit; |
57 | |
58 | /** @var int|null Max allowed entries lists per list */ |
59 | private $entryLimit; |
60 | |
61 | /** @var LoggerInterface */ |
62 | private $logger; |
63 | |
64 | /** @var IDatabase */ |
65 | private $dbw; |
66 | |
67 | /** @var IReadableDatabase */ |
68 | private $dbr; |
69 | |
70 | /** @var int|null */ |
71 | private $userId; |
72 | |
73 | /** @var LBFactory */ |
74 | private $lbFactory; |
75 | |
76 | /** |
77 | * @param int $userId Central ID of the user. |
78 | * @param LBFactory $lbFactory |
79 | */ |
80 | public function __construct( $userId, LBFactory $lbFactory ) { |
81 | $this->userId = (int)$userId ?: null; |
82 | $this->dbw = $lbFactory->getPrimaryDatabase( Utils::VIRTUAL_DOMAIN ); |
83 | $this->dbr = $lbFactory->getReplicaDatabase( Utils::VIRTUAL_DOMAIN ); |
84 | $this->lbFactory = $lbFactory; |
85 | $this->logger = new NullLogger(); |
86 | } |
87 | |
88 | /** |
89 | * @param int|null $listLimit |
90 | * @param int|null $entryLimit |
91 | */ |
92 | public function setLimits( $listLimit, $entryLimit ) { |
93 | $this->listLimit = $listLimit; |
94 | $this->entryLimit = $entryLimit; |
95 | } |
96 | |
97 | /** |
98 | * @param LoggerInterface $logger |
99 | * @return void |
100 | */ |
101 | public function setLogger( LoggerInterface $logger ) { |
102 | $this->logger = $logger; |
103 | } |
104 | |
105 | // setup / teardown |
106 | |
107 | /** |
108 | * Set up the service for the given user. |
109 | * This is a pre-requisite for doing anything else. It will create a default list. |
110 | * @return ReadingListRow The default list for the user. |
111 | * @throws ReadingListRepositoryException |
112 | */ |
113 | public function setupForUser() { |
114 | if ( !$this->hasProjects() ) { |
115 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-projects' ); |
116 | } |
117 | |
118 | $this->assertUser(); |
119 | if ( $this->isSetupForUser( IDBAccessObject::READ_LOCKING ) ) { |
120 | throw new ReadingListRepositoryException( 'readinglists-db-error-already-set-up' ); |
121 | } |
122 | $this->dbw->newInsertQueryBuilder() |
123 | ->insertInto( 'reading_list' ) |
124 | ->row( [ |
125 | 'rl_user_id' => $this->userId, |
126 | 'rl_is_default' => 1, |
127 | 'rl_name' => 'default', |
128 | 'rl_description' => '', |
129 | 'rl_date_created' => $this->dbw->timestamp(), |
130 | 'rl_date_updated' => $this->dbw->timestamp(), |
131 | 'rl_size' => 0, |
132 | 'rl_deleted' => 0, |
133 | ] ) |
134 | ->caller( __METHOD__ )->execute(); |
135 | $this->logger->info( 'Set up for user {user}', [ 'user' => $this->userId ] ); |
136 | $list = $this->selectValidList( $this->dbw->insertId(), IDBAccessObject::READ_LATEST ); |
137 | return $list; |
138 | } |
139 | |
140 | /** |
141 | * Remove all data for the given user. |
142 | * No other operation can be performed for the user except setup. |
143 | * @return void |
144 | * @throws ReadingListRepositoryException |
145 | */ |
146 | public function teardownForUser() { |
147 | $this->assertUser(); |
148 | if ( !$this->isSetupForUser() ) { |
149 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
150 | } |
151 | |
152 | // We use a one-time salt to randomize the deleted list's new |
153 | // name. We can't allow the new name to be fully deterministic, |
154 | // since we could create another list by the same name w/in 30 |
155 | // days. We also can't generate a random name by calling RAND() |
156 | // within the query, since that breaks under statement-level |
157 | // replication. Hence, we hash the current rl_name with a |
158 | // one-time salt. |
159 | $salt = $this->dbw->addQuotes( md5( uniqid( (string)rand(), true ) ) ); |
160 | |
161 | // Soft-delete. Note that no reading list entries are updated; |
162 | // they are effectively orphaned by soft-deletion of their lists |
163 | // and will be batch-removed in purgeOldDeleted(). |
164 | $this->dbw->newUpdateQueryBuilder() |
165 | ->update( 'reading_list' ) |
166 | ->set( [ |
167 | // 'rl_name' is randomized in anticipation of |
168 | // eventually enforcing uniqueness with an |
169 | // index (in which case it can't be limited to |
170 | // non-deleted lists). |
171 | // |
172 | // 'rl_is_default' is cleared so deleted lists |
173 | // aren't detected as defaults. |
174 | // TODO: Use expression buider |
175 | 'rl_name' => new RawSQLValue( "CONCAT('deleted-', MD5(CONCAT( $salt , rl_name)))" ), |
176 | 'rl_is_default' => 0, |
177 | 'rl_deleted' => 1, |
178 | 'rl_date_updated' => $this->dbw->timestamp(), |
179 | ] ) |
180 | ->where( [ 'rl_user_id' => $this->userId ] ) |
181 | ->caller( __METHOD__ )->execute(); |
182 | if ( !$this->dbw->affectedRows() ) { |
183 | $this->logger->error( 'teardownForUser failed for unknown reason', [ |
184 | 'user_central_id' => $this->userId, |
185 | ] ); |
186 | throw new LogicException( 'teardownForUser failed for unknown reason' ); |
187 | } |
188 | |
189 | $this->logger->info( 'Tore down for user {user}', [ 'user' => $this->userId ] ); |
190 | } |
191 | |
192 | /** |
193 | * Check whether reading lists have been set up for the given user (ie. setupForUser() was |
194 | * called with $userId and teardownForUser() was not called with the same id afterwards). |
195 | * Optionally also lock the DB row for the default list of the user (will be used as a |
196 | * semaphore). |
197 | * @param int $flags IDBAccessObject flags |
198 | * @throws ReadingListRepositoryException |
199 | * @return bool |
200 | */ |
201 | public function isSetupForUser( $flags = 0 ) { |
202 | $this->assertUser(); |
203 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
204 | $db = $this->dbw; |
205 | } else { |
206 | $db = $this->dbr; |
207 | } |
208 | $res = $db->newSelectQueryBuilder() |
209 | ->select( '1' ) |
210 | ->from( 'reading_list' ) |
211 | ->where( |
212 | [ |
213 | 'rl_user_id' => $this->userId, |
214 | // It would probably be fine to just check if the user has lists at all, |
215 | // but this way is extra safe against races as setup is the only operation that |
216 | // creates a default list. |
217 | 'rl_is_default' => 1, |
218 | ] |
219 | ) |
220 | ->recency( $flags ) |
221 | ->limit( 1 ) |
222 | ->caller( __METHOD__ )->fetchResultSet(); |
223 | // Until a better 'rl_is_default', log warnings so the bugs are caught. |
224 | $n = $res->numRows(); |
225 | if ( $n > 1 ) { |
226 | $this->logger->warning( 'isSetupForUser for user {user} found {n} default lists', [ |
227 | 'n' => $n, |
228 | 'user' => $this->userId, |
229 | ] ); |
230 | } |
231 | return (bool)$n; |
232 | } |
233 | |
234 | // list CRUD |
235 | |
236 | /** |
237 | * Get list data, and optionally lock the list. |
238 | * List must exist, belong to the current user and not be deleted. |
239 | * @param int $id List id |
240 | * @param int $flags IDBAccessObject flags |
241 | * @return ReadingListRow |
242 | * @throws ReadingListRepositoryException |
243 | * @suppress PhanTypeMismatchReturn Use of doc traits |
244 | */ |
245 | public function selectValidList( $id, $flags = 0 ) { |
246 | $this->assertUser(); |
247 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
248 | $db = $this->dbw; |
249 | } else { |
250 | $db = $this->dbr; |
251 | } |
252 | /** @var ReadingListRow $row */ |
253 | $row = $db->newSelectQueryBuilder() |
254 | ->select( array_merge( $this->getListFields(), [ 'rl_user_id' ] ) ) |
255 | ->from( 'reading_list' ) |
256 | ->where( [ 'rl_id' => $id ] ) |
257 | ->recency( $flags ) |
258 | ->caller( __METHOD__ )->fetchRow(); |
259 | if ( !$row ) { |
260 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); |
261 | } elseif ( $row->rl_user_id != $this->userId ) { |
262 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $id ] ); |
263 | } elseif ( $row->rl_deleted ) { |
264 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-deleted', [ $id ] ); |
265 | } |
266 | return $row; |
267 | } |
268 | |
269 | /** |
270 | * Create a new list. |
271 | * List name is unique for a given user; on conflict, update the existing list. |
272 | * @param string $name |
273 | * @param string $description |
274 | * @return ReadingListRowWithMergeFlag The new (or updated) list. |
275 | * @throws ReadingListRepositoryException |
276 | */ |
277 | public function addList( $name, $description = '' ) { |
278 | $this->assertUser(); |
279 | $this->assertFieldLength( 'rl_name', $name ); |
280 | $this->assertFieldLength( 'rl_description', $description ); |
281 | if ( !$this->isSetupForUser( IDBAccessObject::READ_LOCKING ) ) { |
282 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
283 | } |
284 | if ( $this->listLimit && $this->getListCount( IDBAccessObject::READ_LATEST ) >= $this->listLimit ) { |
285 | // We could check whether the list exists already, in which case we could just |
286 | // update the existing list and return success, but that's too much of an edge case |
287 | // to be worth bothering with. |
288 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-limit', |
289 | [ $this->listLimit ] ); |
290 | } |
291 | |
292 | // rl_user_id + rlname is unique for non-deleted lists. On conflict, update the |
293 | // existing page instead. Also enforce that deleted lists cannot have the same name, |
294 | // in anticipation of eventually using a unique index for list names. |
295 | /** @var ReadingListRow $row */ |
296 | $row = $this->dbw->newSelectQueryBuilder() |
297 | ->select( self::getListFields() ) |
298 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
299 | ->forUpdate() |
300 | ->from( 'reading_list' ) |
301 | ->where( [ 'rl_user_id' => $this->userId, 'rl_name' => $name, ] ) |
302 | ->caller( __METHOD__ )->fetchRow(); |
303 | |
304 | if ( $row === false ) { |
305 | $this->dbw->newInsertQueryBuilder() |
306 | ->insertInto( 'reading_list' ) |
307 | ->row( [ |
308 | 'rl_user_id' => $this->userId, |
309 | 'rl_is_default' => 0, |
310 | 'rl_name' => $name, |
311 | 'rl_description' => $description, |
312 | 'rl_date_created' => $this->dbw->timestamp(), |
313 | 'rl_date_updated' => $this->dbw->timestamp(), |
314 | 'rl_size' => 0, |
315 | 'rl_deleted' => 0, |
316 | ] ) |
317 | ->caller( __METHOD__ )->execute(); |
318 | $id = $this->dbw->insertId(); |
319 | $merged = false; |
320 | } elseif ( $row->rl_deleted ) { |
321 | $this->logger->error( 'Encountered deleted list with non-unique name on insert', [ |
322 | 'rl_id' => $row->rl_id, |
323 | 'rl_name' => $row->rl_name, |
324 | 'user_central_id' => $this->userId, |
325 | ] ); |
326 | throw new LogicException( 'Encountered deleted list with non-unique name on insert' ); |
327 | } elseif ( $row->rl_description === $description ) { |
328 | // List already exists with the same details; nothing to do, just return the ID. |
329 | $id = $row->rl_id; |
330 | $merged = true; |
331 | } else { |
332 | $this->dbw->newUpdateQueryBuilder() |
333 | ->update( 'reading_list' ) |
334 | ->set( [ |
335 | 'rl_description' => $description, |
336 | 'rl_date_updated' => $this->dbw->timestamp(), |
337 | ] ) |
338 | ->where( [ 'rl_id' => $row->rl_id ] ) |
339 | ->caller( __METHOD__ )->execute(); |
340 | $id = $row->rl_id; |
341 | $merged = true; |
342 | } |
343 | $this->logger->info( 'Added list {list} for user {user}', [ |
344 | 'list' => $id, |
345 | 'user' => $this->userId, |
346 | 'merged' => $merged, |
347 | ] ); |
348 | |
349 | // We could just construct the result ourselves but let's be paranoid and re-query it |
350 | // in case some conversion or corruption happens in MySQL. |
351 | /** @var ReadingListRowWithMergeFlag $list */ |
352 | $list = $this->selectValidList( $id, IDBAccessObject::READ_LATEST ); |
353 | '@phan-var ReadingListRowWithMergeFlag $list'; |
354 | $list->merged = $merged; |
355 | return $list; |
356 | } |
357 | |
358 | /** |
359 | * Get all lists of the user. |
360 | * @param string $sortBy One of the SORT_BY_* constants. |
361 | * @param string $sortDir One of the SORT_DIR_* constants. |
362 | * @param int $limit |
363 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
364 | * When sorting by name, this should be the name and id of a list; when sorting by update time, |
365 | * the updated timestamp (in some form accepted by MWTimestamp) and the id. |
366 | * @return IResultWrapper<ReadingListRow> |
367 | * @throws ReadingListRepositoryException |
368 | */ |
369 | public function getAllLists( $sortBy, $sortDir, $limit = 1000, ?array $from = null ) { |
370 | $this->assertUser(); |
371 | [ $conditions, $options ] = $this->processSort( 'rl', $sortBy, $sortDir, $limit, $from ); |
372 | |
373 | $res = $this->dbr->newSelectQueryBuilder() |
374 | ->select( $this->getListFields() ) |
375 | ->from( 'reading_list' ) |
376 | ->where( [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0 ] ) |
377 | ->andWhere( $conditions ) |
378 | ->options( $options ) |
379 | ->caller( __METHOD__ )->fetchResultSet(); |
380 | if ( |
381 | $res->numRows() === 0 |
382 | && !$this->isSetupForUser() |
383 | ) { |
384 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
385 | } |
386 | return $res; |
387 | } |
388 | |
389 | /** |
390 | * Update a list. |
391 | * Fields for which the parameter was set to null will preserve their original value. |
392 | * @param int $id |
393 | * @param string|null $name |
394 | * @param string|null $description |
395 | * @return ReadingListRow The updated list. |
396 | * @throws ReadingListRepositoryException |
397 | * @throws LogicException |
398 | */ |
399 | public function updateList( $id, $name = null, $description = null ) { |
400 | $this->assertUser(); |
401 | $this->assertFieldLength( 'rl_name', $name ); |
402 | $this->assertFieldLength( 'rl_description', $description ); |
403 | $row = $this->selectValidList( $id, IDBAccessObject::READ_LOCKING ); |
404 | if ( $row->rl_is_default ) { |
405 | throw new ReadingListRepositoryException( 'readinglists-db-error-cannot-update-default-list' ); |
406 | } |
407 | |
408 | if ( $name !== null && $name !== $row->rl_name ) { |
409 | /** @var ReadingListRow $row2 */ |
410 | |
411 | $row2 = $this->dbw->newSelectQueryBuilder() |
412 | ->select( self::getListFields() ) |
413 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
414 | ->forUpdate() |
415 | ->from( 'reading_list' ) |
416 | ->where( [ 'rl_user_id' => $this->userId, 'rl_name' => $name, ] ) |
417 | ->caller( __METHOD__ )->fetchRow(); |
418 | |
419 | if ( $row2 !== false && (int)$row2->rl_id !== $id ) { |
420 | if ( $row2->rl_deleted ) { |
421 | $this->logger->error( 'Encountered deleted list with non-unique name on update', [ |
422 | 'this_rl_id' => $row->rl_id, |
423 | 'that_rl_id' => $row2->rl_id, |
424 | 'rl_name' => $row2->rl_name, |
425 | 'user_central_id' => $this->userId, |
426 | ] ); |
427 | throw new LogicException( 'Encountered deleted list with non-unique name on update' ); |
428 | } else { |
429 | throw new ReadingListRepositoryException( 'readinglists-db-error-duplicate-list' ); |
430 | } |
431 | } |
432 | } |
433 | |
434 | $data = array_filter( [ |
435 | 'rl_name' => $name, |
436 | 'rl_description' => $description, |
437 | 'rl_date_updated' => $this->dbw->timestamp(), |
438 | ], static function ( $field ) { |
439 | return $field !== null; |
440 | } ); |
441 | if ( (array)$row === array_merge( (array)$row, $data ) ) { |
442 | // Besides being pointless, this would hit the LogicException below |
443 | return $row; |
444 | } |
445 | |
446 | $this->dbw->newUpdateQueryBuilder() |
447 | ->update( 'reading_list' ) |
448 | ->set( $data ) |
449 | ->where( [ 'rl_id' => $id ] ) |
450 | ->caller( __METHOD__ )->execute(); |
451 | |
452 | if ( !$this->dbw->affectedRows() ) { |
453 | $this->logger->error( 'updateList failed for unknown reason', [ |
454 | 'rl_id' => $row->rl_id, |
455 | 'user_central_id' => $this->userId, |
456 | 'data' => $data, |
457 | ] ); |
458 | throw new LogicException( 'updateList failed for unknown reason' ); |
459 | } |
460 | |
461 | // We could just construct the result ourselves but let's be paranoid and re-query it |
462 | // in case some conversion or corruption happens in MySQL. |
463 | return $this->selectValidList( $id, IDBAccessObject::READ_LATEST ); |
464 | } |
465 | |
466 | /** |
467 | * Delete a list. |
468 | * @param int $id |
469 | * @return void |
470 | * @throws ReadingListRepositoryException |
471 | */ |
472 | public function deleteList( $id ) { |
473 | $this->assertUser(); |
474 | $row = $this->selectValidList( $id, IDBAccessObject::READ_LOCKING ); |
475 | if ( $row->rl_is_default ) { |
476 | throw new ReadingListRepositoryException( 'readinglists-db-error-cannot-delete-default-list' ); |
477 | } |
478 | |
479 | $this->dbw->newUpdateQueryBuilder() |
480 | ->update( 'reading_list' ) |
481 | ->set( [ |
482 | // Randomize the name of deleted lists in anticipation of eventually enforcing |
483 | // uniqueness with an index (in which case it can't be limited to non-deleted lists). |
484 | 'rl_name' => 'deleted-' . md5( uniqid( (string)rand(), true ) ), |
485 | 'rl_deleted' => 1, |
486 | 'rl_date_updated' => $this->dbw->timestamp(), |
487 | ] ) |
488 | ->where( [ 'rl_id' => $id ] ) |
489 | ->caller( __METHOD__ )->execute(); |
490 | if ( !$this->dbw->affectedRows() ) { |
491 | $this->logger->error( 'deleteList failed for unknown reason', [ |
492 | 'rl_id' => $row->rl_id, |
493 | 'user_central_id' => $this->userId, |
494 | ] ); |
495 | throw new LogicException( 'deleteList failed for unknown reason' ); |
496 | } |
497 | |
498 | $this->logger->info( 'Deleted list {list} for user {user}', [ |
499 | 'list' => $id, |
500 | 'user' => $this->userId, |
501 | ] ); |
502 | } |
503 | |
504 | // list entry CRUD |
505 | |
506 | /** |
507 | * Add a new page to a list. |
508 | * When the given page is already on the list, do nothing, just return it. |
509 | * @param int $listId List ID |
510 | * @param string $project Project identifier (typically a domain name) |
511 | * @param string $title Page title (treated as a plain string with no normalization; |
512 | * in localized namespace-prefixed format with spaces is recommended) |
513 | * @return ReadingListEntryRowWithMergeFlag The new (or existing) list entry. |
514 | * @throws ReadingListRepositoryException |
515 | * @suppress PhanTypeMismatchReturn Use of doc traits |
516 | */ |
517 | public function addListEntry( $listId, $project, $title ) { |
518 | $this->assertUser(); |
519 | $this->assertFieldLength( 'rlp_project', $project ); |
520 | $this->assertFieldLength( 'rle_title', $title ); |
521 | $this->selectValidList( $listId, IDBAccessObject::READ_EXCLUSIVE ); |
522 | if ( |
523 | $this->entryLimit |
524 | && $this->getEntryCount( $listId, IDBAccessObject::READ_LATEST ) >= $this->entryLimit |
525 | ) { |
526 | // We could check whether the entry exists already, in which case we could just |
527 | // return success without modifying the entry, but that's too much of an edge case |
528 | // to be worth bothering with. |
529 | throw new ReadingListRepositoryException( 'readinglists-db-error-entry-limit', |
530 | [ $listId, $this->entryLimit ] ); |
531 | } |
532 | |
533 | $projectId = $this->getProjectId( $project ); |
534 | if ( !$projectId ) { |
535 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-project', |
536 | [ $project ] ); |
537 | } |
538 | |
539 | // due to the combination of soft deletion + unique constraint on |
540 | // rle_rl_id + rle_rlp_id + rle_title, recreation needs special handling |
541 | /** @var ReadingListEntryRow $row */ |
542 | $row = $this->dbw->newSelectQueryBuilder() |
543 | ->select( self::getListEntryFields() ) |
544 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
545 | ->forUpdate() |
546 | ->from( 'reading_list_entry' ) |
547 | ->leftJoin( 'reading_list_project', null, 'rle_rlp_id = rlp_id' ) |
548 | ->where( |
549 | [ |
550 | 'rle_rl_id' => $listId, |
551 | 'rle_rlp_id' => $projectId, |
552 | 'rle_title' => $title, |
553 | ] |
554 | ) |
555 | ->caller( __METHOD__ )->fetchRow(); |
556 | if ( $row === false ) { |
557 | $this->dbw->newInsertQueryBuilder() |
558 | ->insertInto( 'reading_list_entry' ) |
559 | ->row( [ |
560 | 'rle_rl_id' => $listId, |
561 | 'rle_user_id' => $this->userId, |
562 | 'rle_rlp_id' => $projectId, |
563 | 'rle_title' => $title, |
564 | 'rle_date_created' => $this->dbw->timestamp(), |
565 | 'rle_date_updated' => $this->dbw->timestamp(), |
566 | 'rle_deleted' => 0, |
567 | ] ) |
568 | ->caller( __METHOD__ )->execute(); |
569 | $entryId = $this->dbw->insertId(); |
570 | $type = 'inserted'; |
571 | } elseif ( $row->rle_deleted ) { |
572 | $this->dbw->newUpdateQueryBuilder() |
573 | ->update( 'reading_list_entry' ) |
574 | ->set( [ |
575 | 'rle_date_created' => $this->dbw->timestamp(), |
576 | 'rle_date_updated' => $this->dbw->timestamp(), |
577 | 'rle_deleted' => 0, |
578 | ] ) |
579 | ->where( [ 'rle_id' => $row->rle_id ] ) |
580 | ->caller( __METHOD__ )->execute(); |
581 | |
582 | $entryId = (int)$row->rle_id; |
583 | $type = 'recreated'; |
584 | } else { |
585 | // The entry already exists, we just need to return its ID. |
586 | $entryId = (int)$row->rle_id; |
587 | $type = 'merged'; |
588 | } |
589 | if ( $type !== 'merged' ) { |
590 | $this->dbw->newUpdateQueryBuilder() |
591 | ->update( 'reading_list' ) |
592 | ->set( [ 'rl_size' => new RawSQLValue( 'rl_size + 1' ) ] ) |
593 | ->where( [ 'rl_id' => $listId ] ) |
594 | ->caller( __METHOD__ )->execute(); |
595 | } |
596 | |
597 | $this->logger->info( 'Added entry {entry} for user {user}', [ |
598 | 'entry' => $entryId, |
599 | 'user' => $this->userId, |
600 | 'type' => $type, |
601 | ] ); |
602 | |
603 | /** @var ReadingListEntryRowWithMergeFlag $row */ |
604 | if ( $type === 'merged' ) { |
605 | $row->merged = true; |
606 | return $row; |
607 | } else { |
608 | $row = $this->dbw->newSelectQueryBuilder() |
609 | ->select( self::getListEntryFields() ) |
610 | ->from( 'reading_list_entry' ) |
611 | ->leftJoin( 'reading_list_project', null, 'rle_rlp_id = rlp_id' ) |
612 | ->where( [ 'rle_id' => $entryId ] ) |
613 | ->caller( __METHOD__ )->fetchRow(); |
614 | |
615 | if ( $row === false ) { |
616 | $this->logger->error( 'Failed to retrieve stored entry', [ |
617 | 'rle_id' => $entryId, |
618 | 'user_central_id' => $this->userId, |
619 | ] ); |
620 | throw new LogicException( 'Failed to retrieve stored entry' ); |
621 | } |
622 | $row->merged = false; |
623 | return $row; |
624 | } |
625 | } |
626 | |
627 | /** |
628 | * Get the entries of one or more lists. |
629 | * @param array $ids List ids |
630 | * @param string $sortBy One of the SORT_BY_* constants. |
631 | * @param string $sortDir One of the SORT_DIR_* constants. |
632 | * @param int $limit |
633 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
634 | * When sorting by name, this should be the name and id of a list; when sorting by update time, |
635 | * the updated timestamp (in some form accepted by MWTimestamp) and the id. |
636 | * @return IResultWrapper<ReadingListEntryRow> |
637 | * @throws ReadingListRepositoryException |
638 | */ |
639 | public function getListEntries( |
640 | array $ids, $sortBy, $sortDir, $limit = 1000, ?array $from = null |
641 | ) { |
642 | $this->assertUser(); |
643 | if ( !$ids ) { |
644 | throw new ReadingListRepositoryException( 'readinglists-db-error-empty-list-ids' ); |
645 | } |
646 | [ $conditions, $options ] = $this->processSort( 'rle', $sortBy, $sortDir, $limit, $from ); |
647 | |
648 | // sanity check for nice error messages |
649 | $res = $this->dbr->newSelectQueryBuilder() |
650 | ->select( [ 'rl_id', 'rl_user_id', 'rl_deleted' ] ) |
651 | ->from( 'reading_list' ) |
652 | ->where( [ 'rl_id' => $ids ] ) |
653 | ->caller( __METHOD__ )->fetchResultSet(); |
654 | $filtered = []; |
655 | foreach ( $res as $row ) { |
656 | /** @var ReadingListRow $row */ |
657 | if ( $row->rl_user_id != $this->userId ) { |
658 | throw new ReadingListRepositoryException( |
659 | 'readinglists-db-error-not-own-list', [ $row->rl_id ] ); |
660 | } elseif ( $row->rl_deleted ) { |
661 | throw new ReadingListRepositoryException( |
662 | 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); |
663 | } |
664 | $filtered[] = $row->rl_id; |
665 | } |
666 | $missing = array_diff( $ids, $filtered ); |
667 | if ( $missing ) { |
668 | throw new ReadingListRepositoryException( |
669 | 'readinglists-db-error-no-such-list', [ reset( $missing ) ] ); |
670 | } |
671 | |
672 | $res = $this->dbr->newSelectQueryBuilder() |
673 | ->select( $this->getListEntryFields() ) |
674 | ->from( 'reading_list_entry' ) |
675 | ->join( 'reading_list_project', null, 'rle_rlp_id = rlp_id' ) |
676 | ->where( [ 'rle_rl_id' => $ids, 'rle_user_id' => $this->userId, 'rle_deleted' => 0 ] ) |
677 | ->andWhere( $conditions ) |
678 | ->options( $options ) |
679 | ->caller( __METHOD__ )->fetchResultSet(); |
680 | |
681 | return $res; |
682 | } |
683 | |
684 | /** |
685 | * Delete a page from a list. |
686 | * @param int $id |
687 | * @return void |
688 | * @throws ReadingListRepositoryException |
689 | */ |
690 | public function deleteListEntry( $id ) { |
691 | $this->assertUser(); |
692 | |
693 | /** @var ReadingListRow|ReadingListEntryRow $row */ |
694 | $row = $this->dbw->newSelectQueryBuilder() |
695 | ->select( [ 'rl_id', 'rl_user_id', 'rl_deleted', 'rle_id', 'rle_deleted' ] ) |
696 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
697 | ->forUpdate() |
698 | ->from( 'reading_list' ) |
699 | ->leftJoin( 'reading_list_entry', null, 'rl_id = rle_rl_id' ) |
700 | ->where( [ 'rle_id' => $id ] ) |
701 | ->caller( __METHOD__ )->fetchRow(); |
702 | if ( !$row ) { |
703 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list-entry', [ $id ] ); |
704 | } elseif ( $row->rl_user_id != $this->userId ) { |
705 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list-entry', [ $id ] ); |
706 | } elseif ( $row->rl_deleted ) { |
707 | throw new ReadingListRepositoryException( |
708 | 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); |
709 | } elseif ( $row->rle_deleted ) { |
710 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-entry-deleted', [ $id ] ); |
711 | } |
712 | |
713 | $this->dbw->newUpdateQueryBuilder() |
714 | ->update( 'reading_list_entry' ) |
715 | ->set( [ |
716 | 'rle_deleted' => 1, |
717 | 'rle_date_updated' => $this->dbw->timestamp(), |
718 | ] ) |
719 | ->where( [ 'rle_id' => $id ] ) |
720 | ->caller( __METHOD__ )->execute(); |
721 | |
722 | if ( !$this->dbw->affectedRows() ) { |
723 | $this->logger->error( 'deleteListEntry failed for unknown reason', [ |
724 | 'rle_id' => $row->rle_id, |
725 | 'user_central_id' => $this->userId, |
726 | ] ); |
727 | throw new LogicException( 'deleteListEntry failed for unknown reason' ); |
728 | } |
729 | $this->dbw->newUpdateQueryBuilder() |
730 | ->update( 'reading_list' ) |
731 | ->set( [ 'rl_size' => new RawSQLValue( 'rl_size - 1' ) ] ) |
732 | ->where( [ |
733 | 'rl_id' => $row->rl_id, |
734 | $this->dbw->expr( 'rl_size', '>', 0 ), |
735 | ] ) |
736 | ->caller( __METHOD__ )->execute(); |
737 | |
738 | $this->logger->info( 'Deleted entry {entry} for user {user}', [ |
739 | 'entry' => $id, |
740 | 'user' => $this->userId, |
741 | ] ); |
742 | } |
743 | |
744 | // sync |
745 | |
746 | /** |
747 | * Get lists that have changed since a given date. |
748 | * Unlike other methods this returns deleted lists as well. Only changes to list metadata |
749 | * (including deletion) are considered, not changes to list entries. |
750 | * @param string $date The cutoff date in TS_MW format |
751 | * @param string $sortBy One of the SORT_BY_* constants. |
752 | * @param string $sortDir One of the SORT_DIR_* constants. |
753 | * @param int $limit |
754 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
755 | * When sorting by name, this should be the name and id of a list; when sorting by update time, |
756 | * the updated timestamp (in some form accepted by MWTimestamp) and the id. |
757 | * @throws ReadingListRepositoryException |
758 | * @return IResultWrapper<ReadingListRow> |
759 | */ |
760 | public function getListsByDateUpdated( $date, $sortBy = self::SORT_BY_UPDATED, |
761 | $sortDir = self::SORT_DIR_ASC, $limit = 1000, ?array $from = null |
762 | ) { |
763 | $this->assertUser(); |
764 | [ $conditions, $options ] = $this->processSort( 'rl', $sortBy, $sortDir, $limit, $from ); |
765 | $res = $this->dbr->newSelectQueryBuilder() |
766 | ->select( $this->getListFields() ) |
767 | ->from( 'reading_list' ) |
768 | ->where( [ |
769 | 'rl_user_id' => $this->userId, |
770 | $this->dbr->expr( 'rl_date_updated', '>', $this->dbr->timestamp( $date ) ) |
771 | ] ) |
772 | ->andWhere( $conditions ) |
773 | ->options( $options ) |
774 | ->caller( __METHOD__ )->fetchResultSet(); |
775 | if ( |
776 | $res->numRows() === 0 |
777 | && !$this->isSetupForUser() |
778 | ) { |
779 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
780 | } |
781 | return $res; |
782 | } |
783 | |
784 | /** |
785 | * Get list entries that have changed since a given date. |
786 | * Unlike other methods this returns deleted entries as well (but not entries inside deleted |
787 | * lists). |
788 | * @param string $date The cutoff date in TS_MW format |
789 | * @param string $sortDir One of the SORT_DIR_* constants. |
790 | * @param int $limit |
791 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
792 | * Should contain the updated timestamp (in some form accepted by MWTimestamp) and the id. |
793 | * @throws ReadingListRepositoryException |
794 | * @return IResultWrapper<ReadingListEntryRow> |
795 | */ |
796 | public function getListEntriesByDateUpdated( |
797 | $date, $sortDir = self::SORT_DIR_ASC, $limit = 1000, ?array $from = null |
798 | ) { |
799 | $this->assertUser(); |
800 | // Always sort by last updated; there is no supporting index for sorting by name. |
801 | [ $conditions, $options ] = $this->processSort( 'rle', self::SORT_BY_UPDATED, |
802 | $sortDir, $limit, $from ); |
803 | $res = $this->dbr->newSelectQueryBuilder() |
804 | ->select( $this->getListEntryFields() ) |
805 | ->from( 'reading_list' ) |
806 | ->join( 'reading_list_entry', null, 'rl_id = rle_rl_id' ) |
807 | ->join( 'reading_list_project', null, 'rle_rlp_id = rlp_id' ) |
808 | ->where( [ |
809 | 'rle_user_id' => $this->userId, |
810 | 'rl_deleted' => 0, |
811 | $this->dbr->expr( 'rle_date_updated', '>', $this->dbr->timestamp( $date ) ) |
812 | ] ) |
813 | ->andWhere( $conditions ) |
814 | ->options( $options ) |
815 | ->caller( __METHOD__ )->fetchResultSet(); |
816 | return $res; |
817 | } |
818 | |
819 | /** |
820 | * Purge all deleted lists/entries older than $before. |
821 | * Unlike most other methods in the class, this one ignores user IDs. |
822 | * @param string $before A timestamp in TS_MW format. |
823 | * @return void |
824 | */ |
825 | public function purgeOldDeleted( $before ) { |
826 | // Purge all soft-deleted, expired entries |
827 | while ( true ) { |
828 | $ids = $this->dbw->newSelectQueryBuilder() |
829 | ->select( 'rle_id' ) |
830 | ->from( 'reading_list_entry' ) |
831 | ->where( [ |
832 | 'rle_deleted' => 1, |
833 | $this->dbw->expr( 'rle_date_updated', '<', $this->dbw->timestamp( $before ) ) |
834 | ] ) |
835 | ->limit( 1000 ) |
836 | ->caller( __METHOD__ )->fetchFieldValues(); |
837 | if ( !$ids ) { |
838 | break; |
839 | } |
840 | $this->dbw->newDeleteQueryBuilder() |
841 | ->deleteFrom( 'reading_list_entry' ) |
842 | ->where( [ 'rle_id' => $ids ] ) |
843 | ->caller( __METHOD__ )->execute(); |
844 | $this->logger->debug( 'Purged {n} entries', [ 'n' => $this->dbw->affectedRows() ] ); |
845 | $this->lbFactory->waitForReplication(); |
846 | } |
847 | |
848 | // Purge all entries on soft-deleted, expired lists |
849 | while ( true ) { |
850 | $ids = $this->dbw->newSelectQueryBuilder() |
851 | ->select( 'rle_id' ) |
852 | ->from( 'reading_list_entry' ) |
853 | ->leftJoin( 'reading_list', null, 'rle_rl_id = rl_id' ) |
854 | ->where( [ |
855 | 'rl_deleted' => 1, |
856 | $this->dbw->expr( 'rl_date_updated', '<', $this->dbw->timestamp( $before ) ) |
857 | ] ) |
858 | ->limit( 1000 ) |
859 | ->caller( __METHOD__ )->fetchFieldValues(); |
860 | if ( !$ids ) { |
861 | break; |
862 | } |
863 | $this->dbw->newDeleteQueryBuilder() |
864 | ->deleteFrom( 'reading_list_entry' ) |
865 | ->where( [ 'rle_id' => $ids ] ) |
866 | ->caller( __METHOD__ )->execute(); |
867 | $this->logger->debug( 'Purged {n} entries', [ 'n' => $this->dbw->affectedRows() ] ); |
868 | $this->lbFactory->waitForReplication(); |
869 | } |
870 | |
871 | // Purge all soft-deleted, expired lists |
872 | while ( true ) { |
873 | $ids = $this->dbw->newSelectQueryBuilder() |
874 | ->select( 'rl_id' ) |
875 | ->from( 'reading_list' ) |
876 | ->where( [ |
877 | 'rl_deleted' => 1, |
878 | $this->dbw->expr( 'rl_date_updated', '<', $this->dbw->timestamp( $before ) ) |
879 | ] ) |
880 | ->limit( 1000 ) |
881 | ->caller( __METHOD__ )->fetchFieldValues(); |
882 | if ( !$ids ) { |
883 | break; |
884 | } |
885 | $this->dbw->newDeleteQueryBuilder() |
886 | ->deleteFrom( 'reading_list' ) |
887 | ->where( [ 'rl_id' => $ids ] ) |
888 | ->caller( __METHOD__ )->execute(); |
889 | $this->logger->debug( 'Purged {n} lists', [ 'n' => $this->dbw->affectedRows() ] ); |
890 | $this->lbFactory->waitForReplication(); |
891 | } |
892 | } |
893 | |
894 | // membership |
895 | |
896 | /** |
897 | * Return all lists which contain a given page. |
898 | * @param string $project Project identifier (typically a domain name) |
899 | * @param string $title Page title (in localized prefixed DBkey format) |
900 | * @param int $limit |
901 | * @param int|null $from List ID to continue from (or null to start at the beginning/end). |
902 | * |
903 | * @throws ReadingListRepositoryException |
904 | * @return IResultWrapper<ReadingListRow> |
905 | */ |
906 | public function getListsByPage( $project, $title, $limit = 1000, $from = null ) { |
907 | $this->assertUser(); |
908 | $projectId = $this->getProjectId( $project ); |
909 | if ( !$projectId ) { |
910 | return new FakeResultWrapper( [] ); |
911 | } |
912 | |
913 | $conditions = [ |
914 | 'rle_user_id' => $this->userId, |
915 | 'rle_rlp_id' => $projectId, |
916 | 'rle_title' => $title, |
917 | 'rl_deleted' => 0, |
918 | 'rle_deleted' => 0, |
919 | ]; |
920 | |
921 | $queryBuilder = $this->dbr->newSelectQueryBuilder() |
922 | ->select( $this->getListFields() ) |
923 | ->from( 'reading_list' ) |
924 | ->join( 'reading_list_entry', null, 'rl_id = rle_rl_id' ) |
925 | ->where( $conditions ) |
926 | // Grouping by rle_rl_id can be done efficiently with the same index used for |
927 | // the conditions. All other fields are functionally dependent on it; MySQL 5.7.5+ |
928 | // can detect that ( https://dev.mysql.com/doc/refman/5.7/en/group-by-handling.html ); |
929 | // MariaDB needs the other fields for ONLY_FULL_GROUP_BY compliance, but they don't |
930 | // seem to negatively affect the query plan. |
931 | ->groupBy( array_merge( [ 'rle_rl_id' ], $this->getListFields() ) ) |
932 | ->orderBy( 'rle_rl_id', 'ASC' ) |
933 | ->limit( (int)$limit ) |
934 | ->caller( __METHOD__ ); |
935 | |
936 | if ( $from !== null ) { |
937 | $queryBuilder->andWhere( |
938 | $this->dbw->expr( 'rle_rl_id', '>=', (int)$from ) |
939 | ); |
940 | } |
941 | $res = $queryBuilder->fetchResultSet(); |
942 | if ( |
943 | $res->numRows() === 0 |
944 | && !$this->isSetupForUser() |
945 | ) { |
946 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
947 | } |
948 | return $res; |
949 | } |
950 | |
951 | /** |
952 | * Recalculate the size of the given list. |
953 | * @param int $id |
954 | * @return bool True if the list needed to be fixed. |
955 | * @throws ReadingListRepositoryException |
956 | */ |
957 | public function fixListSize( $id ) { |
958 | $this->dbw->startAtomic( __METHOD__ ); |
959 | $oldSize = $this->dbw->newSelectQueryBuilder() |
960 | ->select( 'rl_size' ) |
961 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
962 | ->forUpdate() |
963 | ->from( 'reading_list' ) |
964 | ->where( [ 'rl_id' => $id ] ) |
965 | ->caller( __METHOD__ )->fetchField(); |
966 | if ( $oldSize === false ) { |
967 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); |
968 | } |
969 | |
970 | $count = $this->dbw->newSelectQueryBuilder() |
971 | ->select( 'count(*)' ) |
972 | ->from( 'reading_list_entry' ) |
973 | ->where( [ 'rle_rl_id' => $id, 'rle_deleted' => 0, ] ) |
974 | ->groupBy( 'rle_rl_id' ) |
975 | ->caller( __METHOD__ )->fetchField(); |
976 | $this->dbw->newUpdateQueryBuilder() |
977 | ->update( 'reading_list' ) |
978 | ->set( [ 'rl_size' => $count ] ) |
979 | ->where( [ |
980 | 'rl_id' => $id, |
981 | $this->dbw->expr( 'rl_size', '!=', (int)$count ) |
982 | ] ) |
983 | ->caller( __METHOD__ )->execute(); |
984 | |
985 | // Release the lock when using explicit transactions (called from a long-running script). |
986 | $this->dbw->endAtomic( __METHOD__ ); |
987 | return (bool)$this->dbw->affectedRows(); |
988 | } |
989 | |
990 | // helper methods |
991 | |
992 | /** |
993 | * Get this list of reading_list fields that normally need to be selected. |
994 | * @return array |
995 | */ |
996 | private function getListFields() { |
997 | return [ |
998 | 'rl_id', |
999 | // returning rl_user_id is pointless as lists are only available to the owner |
1000 | 'rl_is_default', |
1001 | 'rl_name', |
1002 | 'rl_description', |
1003 | 'rl_date_created', |
1004 | 'rl_date_updated', |
1005 | // skip rl_size, it's only used internally by getEntryCount and entry insert/delete |
1006 | 'rl_deleted', |
1007 | ]; |
1008 | } |
1009 | |
1010 | /** |
1011 | * Get this list of reading_list_entry fields that normally need to be selected. |
1012 | * Can only be used with queries that join on reading_list_project. |
1013 | * @return array |
1014 | */ |
1015 | private function getListEntryFields() { |
1016 | return [ |
1017 | 'rle_id', |
1018 | 'rle_rl_id', |
1019 | // returning rle_user_id is pointless as lists are only available to the owner |
1020 | // skip rle_rlp_id, it's only needed for the join |
1021 | 'rlp_project', |
1022 | 'rle_title', |
1023 | 'rle_date_created', |
1024 | 'rle_date_updated', |
1025 | 'rle_deleted', |
1026 | ]; |
1027 | } |
1028 | |
1029 | /** |
1030 | * Require the user to be specified. |
1031 | * @throws ReadingListRepositoryException |
1032 | */ |
1033 | private function assertUser() { |
1034 | if ( !is_int( $this->userId ) ) { |
1035 | throw new ReadingListRepositoryException( 'readinglists-db-error-user-required' ); |
1036 | } |
1037 | } |
1038 | |
1039 | /** |
1040 | * Ensures that the value to be written to the database does not exceed the DB field length. |
1041 | * @param string $field Field name. |
1042 | * @param string $value Value to write. |
1043 | * @throws ReadingListRepositoryException |
1044 | */ |
1045 | private function assertFieldLength( $field, $value ) { |
1046 | if ( !isset( self::$fieldLength[$field] ) ) { |
1047 | throw new LogicException( 'Tried to assert length for invalid field ' . $field ); |
1048 | } |
1049 | if ( strlen( $value ?? '' ) > self::$fieldLength[$field] ) { |
1050 | throw new ReadingListRepositoryException( 'readinglists-db-error-too-long', |
1051 | [ $field, self::$fieldLength[$field] ] ); |
1052 | } |
1053 | } |
1054 | |
1055 | /** |
1056 | * Validate sort parameters. |
1057 | * @param string $tablePrefix 'rl' or 'rle', depending on whether we are sorting lists or entries. |
1058 | * @param string $sortBy |
1059 | * @param string $sortDir |
1060 | * @param int $limit |
1061 | * @param array|null $from [sortby-value, id] |
1062 | * @return array [ conditions, options ] Merge these into the corresponding IDatabase::select |
1063 | * parameters. |
1064 | */ |
1065 | private function processSort( $tablePrefix, $sortBy, $sortDir, $limit, $from ) { |
1066 | if ( !in_array( $sortBy, [ self::SORT_BY_NAME, self::SORT_BY_UPDATED ], true ) ) { |
1067 | throw new LogicException( 'Invalid $sortBy parameter: ' . $sortBy ); |
1068 | } |
1069 | if ( !in_array( $sortDir, [ self::SORT_DIR_ASC, self::SORT_DIR_DESC ], true ) ) { |
1070 | throw new LogicException( 'Invalid $sortDir parameter: ' . $sortDir ); |
1071 | } |
1072 | if ( is_array( $from ) ) { |
1073 | if ( count( $from ) !== 2 || !is_string( $from[0] ) || !is_numeric( $from[1] ) ) { |
1074 | throw new LogicException( 'Invalid $from parameter' ); |
1075 | } |
1076 | } elseif ( $from !== null ) { |
1077 | throw new LogicException( 'Invalid $from parameter type: ' . get_debug_type( $from ) ); |
1078 | } |
1079 | |
1080 | if ( $tablePrefix === 'rl' ) { |
1081 | $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 'rl_name' : 'rl_date_updated'; |
1082 | } else { |
1083 | $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 'rle_title' : 'rle_date_updated'; |
1084 | } |
1085 | $idField = "{$tablePrefix}_id"; |
1086 | $conditions = []; |
1087 | $options = [ |
1088 | 'ORDER BY' => [ "$mainField $sortDir" ], |
1089 | 'LIMIT' => (int)$limit, |
1090 | ]; |
1091 | // List names are unique and need no tiebreaker. |
1092 | if ( $sortBy !== self::SORT_BY_NAME || $tablePrefix !== 'rl' ) { |
1093 | $options['ORDER BY'][] = "$idField $sortDir"; |
1094 | } |
1095 | |
1096 | if ( $from !== null ) { |
1097 | $op = ( $sortDir === self::SORT_DIR_ASC ) ? '>' : '<'; |
1098 | $safeFromMain = ( $sortBy === self::SORT_BY_NAME ) |
1099 | ? $from[0] |
1100 | : $this->dbr->timestamp( $from[0] ); |
1101 | $safeFromId = (int)$from[1]; |
1102 | // List names are unique and need no tiebreaker. |
1103 | if ( $sortBy === self::SORT_BY_NAME && $tablePrefix === 'rl' ) { |
1104 | $condition = $this->dbw->expr( $mainField, "$op=", $safeFromMain ); |
1105 | } else { |
1106 | $condition = $this->dbr->buildComparison( "$op=", [ |
1107 | $mainField => $safeFromMain, |
1108 | $idField => $safeFromId |
1109 | ] ); |
1110 | } |
1111 | $conditions[] = $condition; |
1112 | } |
1113 | |
1114 | // note: $conditions will be array_merge-d so it should not contain non-numeric keys |
1115 | return [ $conditions, $options ]; |
1116 | } |
1117 | |
1118 | /** |
1119 | * Returns the number of (non-deleted) lists of the current user. |
1120 | * @param int $flags IDBAccessObject flags |
1121 | * @return int |
1122 | */ |
1123 | private function getListCount( $flags = 0 ) { |
1124 | $this->assertUser(); |
1125 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
1126 | $db = $this->dbw; |
1127 | } else { |
1128 | $db = $this->dbr; |
1129 | } |
1130 | return $db->newSelectQueryBuilder() |
1131 | ->select( '1' ) |
1132 | ->from( 'reading_list' ) |
1133 | ->where( [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0, ] ) |
1134 | ->recency( $flags ) |
1135 | ->caller( __METHOD__ )->fetchRowCount(); |
1136 | } |
1137 | |
1138 | /** |
1139 | * Look up a project ID. |
1140 | * @param string $project |
1141 | * @return int|null |
1142 | */ |
1143 | private function getProjectId( $project ) { |
1144 | if ( $project === '@local' ) { |
1145 | // Support for "@local" is provided primarily for the benefit |
1146 | // of Mocha tests, so they don't have to know the actual project |
1147 | // URL. |
1148 | $project = $this->getLocalProject(); |
1149 | } |
1150 | |
1151 | $id = $this->dbr->newSelectQueryBuilder() |
1152 | ->select( 'rlp_id' ) |
1153 | ->from( 'reading_list_project' ) |
1154 | ->where( [ 'rlp_project' => $project ] ) |
1155 | ->caller( __METHOD__ )->fetchField(); |
1156 | return $id === false ? null : (int)$id; |
1157 | } |
1158 | |
1159 | /** |
1160 | * Returns the number of (non-deleted) list entries of the given list. |
1161 | * Verifying that the list is valid is caller's responsibility. |
1162 | * @param int $id List id |
1163 | * @param int $flags IDBAccessObject flags |
1164 | * @return int |
1165 | */ |
1166 | private function getEntryCount( $id, $flags = 0 ) { |
1167 | $this->assertUser(); |
1168 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
1169 | $db = $this->dbw; |
1170 | } else { |
1171 | $db = $this->dbr; |
1172 | } |
1173 | return (int)$db->newSelectQueryBuilder() |
1174 | ->select( 'rl_size' ) |
1175 | ->from( 'reading_list' ) |
1176 | ->where( [ 'rl_id' => $id ] ) |
1177 | ->recency( $flags ) |
1178 | ->caller( __METHOD__ )->fetchField(); |
1179 | } |
1180 | |
1181 | /** |
1182 | * Confirm that at least one project exists. Create one if necessary. |
1183 | * Projects are global to a wiki/wiki farm. Wiki farms using the SiteMatrix |
1184 | * extension should initialize their projects via maintenance script. |
1185 | * |
1186 | * @return bool True if projects were initialized. |
1187 | */ |
1188 | public function initializeProjectIfNeeded(): bool { |
1189 | if ( $this->hasProjects() ) { |
1190 | return false; |
1191 | } |
1192 | |
1193 | $project = $this->getLocalProject(); |
1194 | if ( !$project ) { |
1195 | throw new LogicException( 'Unable to load canonical url for project initialization' ); |
1196 | } |
1197 | |
1198 | $this->dbw->newInsertQueryBuilder() |
1199 | ->insertInto( 'reading_list_project' ) |
1200 | ->row( [ |
1201 | 'rlp_project' => $project, |
1202 | ] ) |
1203 | ->caller( __METHOD__ )->execute(); |
1204 | |
1205 | return true; |
1206 | } |
1207 | |
1208 | /** |
1209 | * Whether any projects have been registered for use with reading lists. |
1210 | * If no projects have been registered, it will not be possible to |
1211 | * add entries to lists. |
1212 | */ |
1213 | public function hasProjects(): bool { |
1214 | $count = $this->dbr->newSelectQueryBuilder() |
1215 | ->select( 'COUNT(*)' )->from( |
1216 | 'reading_list_project' |
1217 | )->caller( __METHOD__ )->fetchField(); |
1218 | |
1219 | return $count > 0; |
1220 | } |
1221 | |
1222 | /** |
1223 | * @return string |
1224 | */ |
1225 | private function getLocalProject(): string { |
1226 | $url = MediaWikiServices::getInstance()->getUrlUtils()->getCanonicalServer(); |
1227 | if ( $url === '' ) { |
1228 | return ''; |
1229 | } |
1230 | |
1231 | $parts = MediaWikiServices::getInstance()->getUrlUtils()->parse( $url ); |
1232 | $parts['port'] = null; |
1233 | $project = MediaWikiServices::getInstance()->getUrlUtils()->assemble( $parts ); |
1234 | |
1235 | return $project; |
1236 | } |
1237 | } |