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