Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.97% |
585 / 665 |
|
38.71% |
12 / 31 |
CRAP | |
0.00% |
0 / 1 |
| ReadingListRepository | |
87.97% |
585 / 665 |
|
38.71% |
12 / 31 |
147.91 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
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 | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
4 | |||
| teardownForUser | |
81.82% |
18 / 22 |
|
0.00% |
0 / 1 |
3.05 | |||
| getDefaultListIdForUser | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
2 | |||
| 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% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| updateList | |
69.57% |
32 / 46 |
|
0.00% |
0 / 1 |
11.28 | |||
| deleteList | |
81.48% |
22 / 27 |
|
0.00% |
0 / 1 |
3.06 | |||
| addListEntry | |
93.98% |
78 / 83 |
|
0.00% |
0 / 1 |
9.02 | |||
| getListEntries | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
6 | |||
| getAllListEntries | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
| deleteListEntry | |
88.64% |
39 / 44 |
|
0.00% |
0 / 1 |
6.05 | |||
| getListsByDateUpdated | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| getListEntriesByDateUpdated | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
| purgeOldDeleted | |
100.00% |
55 / 55 |
|
100.00% |
1 / 1 |
7 | |||
| getListsByPage | |
96.67% |
29 / 30 |
|
0.00% |
0 / 1 |
6 | |||
| fixListSize | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
6 | |||
| getListFields | |
100.00% |
10 / 10 |
|
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 | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
18.53 | |||
| 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 ?int $userId; |
| 72 | |
| 73 | /** |
| 74 | * @param ?int $userId Central ID of the user. |
| 75 | * @param LBFactory $lbFactory |
| 76 | */ |
| 77 | public function __construct( |
| 78 | ?int $userId, |
| 79 | private readonly LBFactory $lbFactory |
| 80 | ) { |
| 81 | $this->userId = (int)$userId ?: null; |
| 82 | $this->dbw = $lbFactory->getPrimaryDatabase( Utils::VIRTUAL_DOMAIN ); |
| 83 | $this->dbr = $lbFactory->getReplicaDatabase( Utils::VIRTUAL_DOMAIN ); |
| 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 | public function setLogger( LoggerInterface $logger ): void { |
| 97 | $this->logger = $logger; |
| 98 | } |
| 99 | |
| 100 | // setup / teardown |
| 101 | |
| 102 | /** |
| 103 | * Set up the service for the given user. |
| 104 | * This is a pre-requisite for doing anything else. It will create a default list. |
| 105 | * @param bool $silent When true, returns the default list instead of throwing if already set up. |
| 106 | * @return ReadingListRow The default list for the user. |
| 107 | * @throws ReadingListRepositoryException |
| 108 | */ |
| 109 | public function setupForUser( $silent = false ) { |
| 110 | if ( !$this->hasProjects() ) { |
| 111 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-projects' ); |
| 112 | } |
| 113 | |
| 114 | $this->assertUser(); |
| 115 | $defaultListId = $this->getDefaultListIdForUser( IDBAccessObject::READ_LOCKING ); |
| 116 | |
| 117 | // Bypass the setup process if the default list already exists |
| 118 | if ( $defaultListId !== false ) { |
| 119 | // Older code may expect the exception behavior, so only return if $silent = true |
| 120 | if ( $silent ) { |
| 121 | return $this->selectValidList( $defaultListId, IDBAccessObject::READ_LATEST ); |
| 122 | } |
| 123 | |
| 124 | throw new ReadingListRepositoryException( 'readinglists-db-error-already-set-up' ); |
| 125 | } |
| 126 | |
| 127 | $this->dbw->newInsertQueryBuilder() |
| 128 | ->insertInto( 'reading_list' ) |
| 129 | ->row( [ |
| 130 | 'rl_user_id' => $this->userId, |
| 131 | 'rl_is_default' => 1, |
| 132 | 'rl_name' => 'default', |
| 133 | 'rl_description' => '', |
| 134 | 'rl_date_created' => $this->dbw->timestamp(), |
| 135 | 'rl_date_updated' => $this->dbw->timestamp(), |
| 136 | 'rl_size' => 0, |
| 137 | 'rl_deleted' => 0, |
| 138 | ] ) |
| 139 | ->caller( __METHOD__ )->execute(); |
| 140 | $this->logger->info( 'Set up for user {user}', [ 'user' => $this->userId ] ); |
| 141 | $list = $this->selectValidList( $this->dbw->insertId(), IDBAccessObject::READ_LATEST ); |
| 142 | return $list; |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Remove all data for the given user. |
| 147 | * No other operation can be performed for the user except setup. |
| 148 | * @return void |
| 149 | * @throws ReadingListRepositoryException |
| 150 | */ |
| 151 | public function teardownForUser() { |
| 152 | $this->assertUser(); |
| 153 | if ( !$this->getDefaultListIdForUser() ) { |
| 154 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
| 155 | } |
| 156 | |
| 157 | // Soft-delete. Note that no reading list entries are updated; |
| 158 | // they are effectively orphaned by soft-deletion of their lists |
| 159 | // and will be batch-removed in purgeOldDeleted(). |
| 160 | $this->dbw->newUpdateQueryBuilder() |
| 161 | ->update( 'reading_list' ) |
| 162 | ->set( [ |
| 163 | // 'rl_name' is randomized in anticipation of |
| 164 | // eventually enforcing uniqueness with an |
| 165 | // index (in which case it can't be limited to |
| 166 | // non-deleted lists). |
| 167 | 'rl_name' => new RawSQLValue( $this->dbw->buildConcat( [ |
| 168 | $this->dbw->addQuotes( 'deleted-' ), |
| 169 | 'rl_name', |
| 170 | $this->dbw->addQuotes( '-' . bin2hex( random_bytes( 16 ) ) ) |
| 171 | ] ) ), |
| 172 | 'rl_deleted' => 1, |
| 173 | 'rl_date_updated' => $this->dbw->timestamp(), |
| 174 | ] ) |
| 175 | ->where( [ 'rl_user_id' => $this->userId ] ) |
| 176 | ->caller( __METHOD__ )->execute(); |
| 177 | if ( !$this->dbw->affectedRows() ) { |
| 178 | $this->logger->error( 'teardownForUser failed for unknown reason', [ |
| 179 | 'user_central_id' => $this->userId, |
| 180 | ] ); |
| 181 | throw new LogicException( 'teardownForUser failed for unknown reason' ); |
| 182 | } |
| 183 | |
| 184 | $this->logger->info( 'Tore down for user {user}', [ 'user' => $this->userId ] ); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Check whether reading lists have been set up for the given user (i.e. setupForUser() was |
| 189 | * called with $userId and teardownForUser() was not called with the same id afterward). |
| 190 | * |
| 191 | * Optionally also lock the DB row for the default list of the user (will be used as a |
| 192 | * semaphore). |
| 193 | * |
| 194 | * This will return the default list ID if the user has already set up, otherwise false. |
| 195 | * @param int $flags IDBAccessObject flags |
| 196 | * @throws ReadingListRepositoryException |
| 197 | * @return false|int |
| 198 | */ |
| 199 | public function getDefaultListIdForUser( $flags = 0 ) { |
| 200 | $this->assertUser(); |
| 201 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
| 202 | $db = $this->dbw; |
| 203 | } else { |
| 204 | $db = $this->dbr; |
| 205 | } |
| 206 | return $db->newSelectQueryBuilder() |
| 207 | ->select( 'rl_id' ) |
| 208 | ->from( 'reading_list' ) |
| 209 | ->where( |
| 210 | [ |
| 211 | 'rl_user_id' => $this->userId, |
| 212 | // It would probably be fine to just check if the user has lists at all, |
| 213 | // but this way is extra safe against races as setup is the only operation that |
| 214 | // creates a default list. |
| 215 | 'rl_is_default' => 1, |
| 216 | 'rl_deleted' => 0 |
| 217 | ] |
| 218 | ) |
| 219 | ->recency( $flags ) |
| 220 | ->limit( 1 ) |
| 221 | ->caller( __METHOD__ )->fetchField(); |
| 222 | } |
| 223 | |
| 224 | // list CRUD |
| 225 | |
| 226 | /** |
| 227 | * Get list data, and optionally lock the list. |
| 228 | * List must exist, belong to the current user and not be deleted. |
| 229 | * @param int $id List id |
| 230 | * @param int $flags IDBAccessObject flags |
| 231 | * @return ReadingListRow |
| 232 | * @throws ReadingListRepositoryException |
| 233 | * @suppress PhanTypeMismatchReturn Use of doc traits |
| 234 | */ |
| 235 | public function selectValidList( $id, $flags = 0 ) { |
| 236 | $this->assertUser(); |
| 237 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
| 238 | $db = $this->dbw; |
| 239 | } else { |
| 240 | $db = $this->dbr; |
| 241 | } |
| 242 | /** @var ReadingListRow $row */ |
| 243 | $row = $db->newSelectQueryBuilder() |
| 244 | ->select( array_merge( $this->getListFields(), [ 'rl_user_id' ] ) ) |
| 245 | ->from( 'reading_list' ) |
| 246 | ->where( [ 'rl_id' => $id ] ) |
| 247 | ->recency( $flags ) |
| 248 | ->caller( __METHOD__ )->fetchRow(); |
| 249 | if ( !$row ) { |
| 250 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); |
| 251 | } elseif ( $row->rl_user_id != $this->userId ) { |
| 252 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $id ] ); |
| 253 | } elseif ( $row->rl_deleted ) { |
| 254 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-deleted', [ $id ] ); |
| 255 | } |
| 256 | return $row; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Create a new list. |
| 261 | * List name is unique for a given user; on conflict, update the existing list. |
| 262 | * @param string $name |
| 263 | * @param string $description |
| 264 | * @return ReadingListRowWithMergeFlag The new (or updated) list. |
| 265 | * @throws ReadingListRepositoryException |
| 266 | */ |
| 267 | public function addList( $name, $description = '' ) { |
| 268 | $this->assertUser(); |
| 269 | $this->assertFieldLength( 'rl_name', $name ); |
| 270 | $this->assertFieldLength( 'rl_description', $description ); |
| 271 | if ( !$this->getDefaultListIdForUser( IDBAccessObject::READ_LOCKING ) ) { |
| 272 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
| 273 | } |
| 274 | if ( $this->listLimit && $this->getListCount( IDBAccessObject::READ_LATEST ) >= $this->listLimit ) { |
| 275 | // We could check whether the list exists already, in which case we could just |
| 276 | // update the existing list and return success, but that's too much of an edge case |
| 277 | // to be worth bothering with. |
| 278 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-limit', |
| 279 | [ $this->listLimit ] ); |
| 280 | } |
| 281 | |
| 282 | // rl_user_id + rlname is unique for non-deleted lists. On conflict, update the |
| 283 | // existing page instead. Also enforce that deleted lists cannot have the same name, |
| 284 | // in anticipation of eventually using a unique index for list names. |
| 285 | /** @var false|ReadingListRow $row */ |
| 286 | $row = $this->dbw->newSelectQueryBuilder() |
| 287 | ->select( self::getListFields() ) |
| 288 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
| 289 | ->forUpdate() |
| 290 | ->from( 'reading_list' ) |
| 291 | ->where( [ 'rl_user_id' => $this->userId, 'rl_name' => $name, ] ) |
| 292 | ->caller( __METHOD__ )->fetchRow(); |
| 293 | |
| 294 | if ( $row === false ) { |
| 295 | $this->dbw->newInsertQueryBuilder() |
| 296 | ->insertInto( 'reading_list' ) |
| 297 | ->row( [ |
| 298 | 'rl_user_id' => $this->userId, |
| 299 | 'rl_is_default' => 0, |
| 300 | 'rl_name' => $name, |
| 301 | 'rl_description' => $description, |
| 302 | 'rl_date_created' => $this->dbw->timestamp(), |
| 303 | 'rl_date_updated' => $this->dbw->timestamp(), |
| 304 | 'rl_size' => 0, |
| 305 | 'rl_deleted' => 0, |
| 306 | ] ) |
| 307 | ->caller( __METHOD__ )->execute(); |
| 308 | $id = $this->dbw->insertId(); |
| 309 | $merged = false; |
| 310 | } elseif ( $row->rl_deleted ) { |
| 311 | $this->logger->error( 'Encountered deleted list with non-unique name on insert', [ |
| 312 | 'rl_id' => $row->rl_id, |
| 313 | 'rl_name' => $row->rl_name, |
| 314 | 'user_central_id' => $this->userId, |
| 315 | ] ); |
| 316 | throw new LogicException( 'Encountered deleted list with non-unique name on insert' ); |
| 317 | } elseif ( $row->rl_description === $description ) { |
| 318 | // List already exists with the same details; nothing to do, just return the ID. |
| 319 | $id = $row->rl_id; |
| 320 | $merged = true; |
| 321 | } else { |
| 322 | $this->dbw->newUpdateQueryBuilder() |
| 323 | ->update( 'reading_list' ) |
| 324 | ->set( [ |
| 325 | 'rl_description' => $description, |
| 326 | 'rl_date_updated' => $this->dbw->timestamp(), |
| 327 | ] ) |
| 328 | ->where( [ 'rl_id' => $row->rl_id ] ) |
| 329 | ->caller( __METHOD__ )->execute(); |
| 330 | $id = $row->rl_id; |
| 331 | $merged = true; |
| 332 | } |
| 333 | $this->logger->info( 'Added list {list} for user {user}', [ |
| 334 | 'list' => $id, |
| 335 | 'user' => $this->userId, |
| 336 | 'merged' => $merged, |
| 337 | ] ); |
| 338 | |
| 339 | // We could just construct the result ourselves but let's be paranoid and re-query it |
| 340 | // in case some conversion or corruption happens in MySQL. |
| 341 | /** @var ReadingListRowWithMergeFlag $list */ |
| 342 | $list = $this->selectValidList( $id, IDBAccessObject::READ_LATEST ); |
| 343 | '@phan-var ReadingListRowWithMergeFlag $list'; |
| 344 | $list->merged = $merged; |
| 345 | return $list; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Get all lists of the user. |
| 350 | * @param string $sortBy One of the SORT_BY_* constants. |
| 351 | * @param string $sortDir One of the SORT_DIR_* constants. |
| 352 | * @param int $limit |
| 353 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
| 354 | * When sorting by name, this should be the name and id of a list; when sorting by update time, |
| 355 | * the updated timestamp (in some form accepted by MWTimestamp) and the id. |
| 356 | * @return IResultWrapper<ReadingListRow> |
| 357 | * @throws ReadingListRepositoryException |
| 358 | */ |
| 359 | public function getAllLists( $sortBy, $sortDir, $limit = 1000, ?array $from = null ) { |
| 360 | $this->assertUser(); |
| 361 | [ $conditions, $options ] = $this->processSort( 'rl', $sortBy, $sortDir, $limit, $from ); |
| 362 | |
| 363 | // Avoid default list showing on pages > 1, so exclude it and skip order by |
| 364 | if ( $from ) { |
| 365 | array_unshift( $conditions, 'rl_is_default = 0' ); |
| 366 | } else { |
| 367 | array_unshift( $options[ 'ORDER BY' ], 'rl_is_default desc' ); |
| 368 | } |
| 369 | |
| 370 | $res = $this->dbr->newSelectQueryBuilder() |
| 371 | ->select( $this->getListFields() ) |
| 372 | ->from( 'reading_list' ) |
| 373 | ->where( [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0 ] ) |
| 374 | ->andWhere( $conditions ) |
| 375 | ->options( $options ) |
| 376 | ->caller( __METHOD__ )->fetchResultSet(); |
| 377 | if ( |
| 378 | $res->numRows() === 0 |
| 379 | && !$this->getDefaultListIdForUser( IDBAccessObject::READ_LATEST ) |
| 380 | ) { |
| 381 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
| 382 | } |
| 383 | return $res; |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Update a list. |
| 388 | * Fields for which the parameter was set to null will preserve their original value. |
| 389 | * @param int $id |
| 390 | * @param string|null $name |
| 391 | * @param string|null $description |
| 392 | * @return ReadingListRow The updated list. |
| 393 | * @throws ReadingListRepositoryException|LogicException |
| 394 | */ |
| 395 | public function updateList( $id, $name = null, $description = null ) { |
| 396 | $this->assertUser(); |
| 397 | $this->assertFieldLength( 'rl_name', $name ); |
| 398 | $this->assertFieldLength( 'rl_description', $description ); |
| 399 | $row = $this->selectValidList( $id, IDBAccessObject::READ_LOCKING ); |
| 400 | if ( $row->rl_is_default ) { |
| 401 | throw new ReadingListRepositoryException( 'readinglists-db-error-cannot-update-default-list' ); |
| 402 | } |
| 403 | |
| 404 | if ( $name !== null && $name !== $row->rl_name ) { |
| 405 | /** @var false|ReadingListRow $row2 */ |
| 406 | $row2 = $this->dbw->newSelectQueryBuilder() |
| 407 | ->select( self::getListFields() ) |
| 408 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
| 409 | ->forUpdate() |
| 410 | ->from( 'reading_list' ) |
| 411 | ->where( [ 'rl_user_id' => $this->userId, 'rl_name' => $name, ] ) |
| 412 | ->caller( __METHOD__ )->fetchRow(); |
| 413 | |
| 414 | if ( $row2 !== false && (int)$row2->rl_id !== $id ) { |
| 415 | if ( $row2->rl_deleted ) { |
| 416 | $this->logger->error( 'Encountered deleted list with non-unique name on update', [ |
| 417 | 'this_rl_id' => $row->rl_id, |
| 418 | 'that_rl_id' => $row2->rl_id, |
| 419 | 'rl_name' => $row2->rl_name, |
| 420 | 'user_central_id' => $this->userId, |
| 421 | ] ); |
| 422 | throw new LogicException( 'Encountered deleted list with non-unique name on update' ); |
| 423 | } else { |
| 424 | throw new ReadingListRepositoryException( 'readinglists-db-error-duplicate-list' ); |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | $data = array_filter( [ |
| 430 | 'rl_name' => $name, |
| 431 | 'rl_description' => $description, |
| 432 | 'rl_date_updated' => $this->dbw->timestamp(), |
| 433 | ], static function ( $field ) { |
| 434 | return $field !== null; |
| 435 | } ); |
| 436 | if ( (array)$row === array_merge( (array)$row, $data ) ) { |
| 437 | // Besides being pointless, this would hit the LogicException below |
| 438 | return $row; |
| 439 | } |
| 440 | |
| 441 | $this->dbw->newUpdateQueryBuilder() |
| 442 | ->update( 'reading_list' ) |
| 443 | ->set( $data ) |
| 444 | ->where( [ 'rl_id' => $id ] ) |
| 445 | ->caller( __METHOD__ )->execute(); |
| 446 | |
| 447 | if ( !$this->dbw->affectedRows() ) { |
| 448 | $this->logger->error( 'updateList failed for unknown reason', [ |
| 449 | 'rl_id' => $row->rl_id, |
| 450 | 'user_central_id' => $this->userId, |
| 451 | 'data' => $data, |
| 452 | ] ); |
| 453 | throw new LogicException( 'updateList failed for unknown reason' ); |
| 454 | } |
| 455 | |
| 456 | // We could just construct the result ourselves but let's be paranoid and re-query it |
| 457 | // in case some conversion or corruption happens in MySQL. |
| 458 | return $this->selectValidList( $id, IDBAccessObject::READ_LATEST ); |
| 459 | } |
| 460 | |
| 461 | /** |
| 462 | * Delete a list. |
| 463 | * @param int $id |
| 464 | * @return void |
| 465 | * @throws ReadingListRepositoryException |
| 466 | */ |
| 467 | public function deleteList( $id ) { |
| 468 | $this->assertUser(); |
| 469 | $row = $this->selectValidList( $id, IDBAccessObject::READ_LOCKING ); |
| 470 | if ( $row->rl_is_default ) { |
| 471 | throw new ReadingListRepositoryException( 'readinglists-db-error-cannot-delete-default-list' ); |
| 472 | } |
| 473 | |
| 474 | $this->dbw->newUpdateQueryBuilder() |
| 475 | ->update( 'reading_list' ) |
| 476 | ->set( [ |
| 477 | // Randomize the name of deleted lists in anticipation of eventually enforcing |
| 478 | // uniqueness with an index (in which case it can't be limited to non-deleted lists). |
| 479 | 'rl_name' => new RawSQLValue( $this->dbw->buildConcat( [ |
| 480 | $this->dbw->addQuotes( 'deleted-' ), |
| 481 | 'rl_name', |
| 482 | $this->dbw->addQuotes( '-' . bin2hex( random_bytes( 16 ) ) ) |
| 483 | ] ) ), |
| 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 false|ReadingListEntryRowWithMergeFlag $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' => new RawSQLValue( '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 false|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( [ |
| 676 | 'rle_rl_id' => $ids, |
| 677 | 'rle_user_id' => $this->userId, |
| 678 | 'rle_deleted' => 0 |
| 679 | ] ) |
| 680 | ->andWhere( $conditions ) |
| 681 | ->options( $options ) |
| 682 | ->caller( __METHOD__ )->fetchResultSet(); |
| 683 | |
| 684 | return $res; |
| 685 | } |
| 686 | |
| 687 | /** |
| 688 | * Get all list entries for the user. |
| 689 | * |
| 690 | * @param string $sortBy One of the SORT_BY_* constants. |
| 691 | * @param string $sortDir One of the SORT_DIR_* constants. |
| 692 | * @param int $limit |
| 693 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
| 694 | * @throws ReadingListRepositoryException |
| 695 | * @return IResultWrapper<ReadingListEntryRow> |
| 696 | */ |
| 697 | public function getAllListEntries( |
| 698 | string $sortBy = self::SORT_BY_UPDATED, |
| 699 | string $sortDir = self::SORT_DIR_ASC, |
| 700 | int $limit = 1000, |
| 701 | ?array $from = null |
| 702 | ) { |
| 703 | $this->assertUser(); |
| 704 | |
| 705 | // get all list ids for the user |
| 706 | $listIds = $this->dbr->newSelectQueryBuilder() |
| 707 | ->select( 'rl_id' ) |
| 708 | ->from( 'reading_list' ) |
| 709 | ->where( [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0 ] ) |
| 710 | ->caller( __METHOD__ )->fetchFieldValues(); |
| 711 | |
| 712 | if ( !$listIds ) { |
| 713 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-lists' ); |
| 714 | } |
| 715 | |
| 716 | return $this->getListEntries( $listIds, $sortBy, $sortDir, $limit, $from ); |
| 717 | } |
| 718 | |
| 719 | /** |
| 720 | * Delete a page from a list. |
| 721 | * @param int $id |
| 722 | * @return void |
| 723 | * @throws ReadingListRepositoryException |
| 724 | */ |
| 725 | public function deleteListEntry( $id ) { |
| 726 | $this->assertUser(); |
| 727 | |
| 728 | /** @var ReadingListRow|ReadingListEntryRow $row */ |
| 729 | $row = $this->dbw->newSelectQueryBuilder() |
| 730 | ->select( [ 'rl_id', 'rl_user_id', 'rl_deleted', 'rle_id', 'rle_deleted' ] ) |
| 731 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
| 732 | ->forUpdate() |
| 733 | ->from( 'reading_list' ) |
| 734 | ->leftJoin( 'reading_list_entry', null, 'rl_id = rle_rl_id' ) |
| 735 | ->where( [ 'rle_id' => $id ] ) |
| 736 | ->caller( __METHOD__ )->fetchRow(); |
| 737 | if ( !$row ) { |
| 738 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list-entry', [ $id ] ); |
| 739 | } elseif ( $row->rl_user_id != $this->userId ) { |
| 740 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list-entry', [ $id ] ); |
| 741 | } elseif ( $row->rl_deleted ) { |
| 742 | throw new ReadingListRepositoryException( |
| 743 | 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); |
| 744 | } elseif ( $row->rle_deleted ) { |
| 745 | throw new ReadingListRepositoryException( 'readinglists-db-error-list-entry-deleted', [ $id ] ); |
| 746 | } |
| 747 | |
| 748 | $this->dbw->newUpdateQueryBuilder() |
| 749 | ->update( 'reading_list_entry' ) |
| 750 | ->set( [ |
| 751 | 'rle_deleted' => 1, |
| 752 | 'rle_date_updated' => $this->dbw->timestamp(), |
| 753 | ] ) |
| 754 | ->where( [ 'rle_id' => $id ] ) |
| 755 | ->caller( __METHOD__ )->execute(); |
| 756 | |
| 757 | if ( !$this->dbw->affectedRows() ) { |
| 758 | $this->logger->error( 'deleteListEntry failed for unknown reason', [ |
| 759 | 'rle_id' => $row->rle_id, |
| 760 | 'user_central_id' => $this->userId, |
| 761 | ] ); |
| 762 | throw new LogicException( 'deleteListEntry failed for unknown reason' ); |
| 763 | } |
| 764 | $this->dbw->newUpdateQueryBuilder() |
| 765 | ->update( 'reading_list' ) |
| 766 | ->set( [ 'rl_size' => new RawSQLValue( 'rl_size - 1' ) ] ) |
| 767 | ->where( [ |
| 768 | 'rl_id' => $row->rl_id, |
| 769 | $this->dbw->expr( 'rl_size', '>', 0 ), |
| 770 | ] ) |
| 771 | ->caller( __METHOD__ )->execute(); |
| 772 | |
| 773 | $this->logger->info( 'Deleted entry {entry} for user {user}', [ |
| 774 | 'entry' => $id, |
| 775 | 'user' => $this->userId, |
| 776 | ] ); |
| 777 | } |
| 778 | |
| 779 | // sync |
| 780 | |
| 781 | /** |
| 782 | * Get lists that have changed since a given date. |
| 783 | * Unlike other methods this returns deleted lists as well. Only changes to list metadata |
| 784 | * (including deletion) are considered, not changes to list entries. |
| 785 | * @param string $date The cutoff date in TS_MW format |
| 786 | * @param string $sortBy One of the SORT_BY_* constants. |
| 787 | * @param string $sortDir One of the SORT_DIR_* constants. |
| 788 | * @param int $limit |
| 789 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
| 790 | * When sorting by name, this should be the name and id of a list; when sorting by update time, |
| 791 | * the updated timestamp (in some form accepted by MWTimestamp) and the id. |
| 792 | * @throws ReadingListRepositoryException |
| 793 | * @return IResultWrapper<ReadingListRow> |
| 794 | */ |
| 795 | public function getListsByDateUpdated( |
| 796 | $date, |
| 797 | $sortBy = self::SORT_BY_UPDATED, |
| 798 | $sortDir = self::SORT_DIR_ASC, |
| 799 | $limit = 1000, |
| 800 | ?array $from = null |
| 801 | ) { |
| 802 | $this->assertUser(); |
| 803 | [ $conditions, $options ] = $this->processSort( 'rl', $sortBy, $sortDir, $limit, $from ); |
| 804 | |
| 805 | // Avoid default list showing on pages > 1, so exclude it and skip order by |
| 806 | if ( $from ) { |
| 807 | array_unshift( $conditions, 'rl_is_default = 0' ); |
| 808 | } else { |
| 809 | array_unshift( $options[ 'ORDER BY' ], 'rl_is_default desc' ); |
| 810 | } |
| 811 | |
| 812 | $res = $this->dbr->newSelectQueryBuilder() |
| 813 | ->select( $this->getListFields() ) |
| 814 | ->from( 'reading_list' ) |
| 815 | ->where( [ |
| 816 | 'rl_user_id' => $this->userId, |
| 817 | $this->dbr->expr( 'rl_date_updated', '>', $this->dbr->timestamp( $date ) ) |
| 818 | ] ) |
| 819 | ->andWhere( $conditions ) |
| 820 | ->options( $options ) |
| 821 | ->caller( __METHOD__ )->fetchResultSet(); |
| 822 | if ( |
| 823 | $res->numRows() === 0 |
| 824 | && !$this->getDefaultListIdForUser( IDBAccessObject::READ_LATEST ) |
| 825 | ) { |
| 826 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
| 827 | } |
| 828 | return $res; |
| 829 | } |
| 830 | |
| 831 | /** |
| 832 | * Get list entries that have changed since a given date. |
| 833 | * Unlike other methods this returns deleted entries as well (but not entries inside deleted |
| 834 | * lists). |
| 835 | * @param string $date The cutoff date in TS_MW format |
| 836 | * @param string $sortDir One of the SORT_DIR_* constants. |
| 837 | * @param int $limit |
| 838 | * @param array|null $from DB position to continue from (or null to start at the beginning/end). |
| 839 | * Should contain the updated timestamp (in some form accepted by MWTimestamp) and the id. |
| 840 | * @throws ReadingListRepositoryException |
| 841 | * @return IResultWrapper<ReadingListEntryRow> |
| 842 | */ |
| 843 | public function getListEntriesByDateUpdated( |
| 844 | $date, $sortDir = self::SORT_DIR_ASC, $limit = 1000, ?array $from = null |
| 845 | ) { |
| 846 | $this->assertUser(); |
| 847 | // Always sort by last updated; there is no supporting index for sorting by name. |
| 848 | [ $conditions, $options ] = $this->processSort( 'rle', self::SORT_BY_UPDATED, |
| 849 | $sortDir, $limit, $from ); |
| 850 | $res = $this->dbr->newSelectQueryBuilder() |
| 851 | ->select( $this->getListEntryFields() ) |
| 852 | ->from( 'reading_list' ) |
| 853 | ->join( 'reading_list_entry', null, 'rl_id = rle_rl_id' ) |
| 854 | ->join( 'reading_list_project', null, 'rle_rlp_id = rlp_id' ) |
| 855 | ->where( [ |
| 856 | 'rle_user_id' => $this->userId, |
| 857 | 'rl_deleted' => 0, |
| 858 | $this->dbr->expr( 'rle_date_updated', '>', $this->dbr->timestamp( $date ) ) |
| 859 | ] ) |
| 860 | ->andWhere( $conditions ) |
| 861 | ->options( $options ) |
| 862 | ->caller( __METHOD__ )->fetchResultSet(); |
| 863 | return $res; |
| 864 | } |
| 865 | |
| 866 | /** |
| 867 | * Purge all deleted lists/entries older than $before. |
| 868 | * Unlike most other methods in the class, this one ignores user IDs. |
| 869 | * @param string $before A timestamp in TS_MW format. |
| 870 | * @return void |
| 871 | */ |
| 872 | public function purgeOldDeleted( $before ) { |
| 873 | // Purge all soft-deleted, expired entries |
| 874 | while ( true ) { |
| 875 | $ids = $this->dbw->newSelectQueryBuilder() |
| 876 | ->select( 'rle_id' ) |
| 877 | ->from( 'reading_list_entry' ) |
| 878 | ->where( [ |
| 879 | 'rle_deleted' => 1, |
| 880 | $this->dbw->expr( 'rle_date_updated', '<', $this->dbw->timestamp( $before ) ) |
| 881 | ] ) |
| 882 | ->limit( 1000 ) |
| 883 | ->caller( __METHOD__ )->fetchFieldValues(); |
| 884 | if ( !$ids ) { |
| 885 | break; |
| 886 | } |
| 887 | $this->dbw->newDeleteQueryBuilder() |
| 888 | ->deleteFrom( 'reading_list_entry' ) |
| 889 | ->where( [ 'rle_id' => $ids ] ) |
| 890 | ->caller( __METHOD__ )->execute(); |
| 891 | $this->logger->debug( 'Purged {n} entries', [ 'n' => $this->dbw->affectedRows() ] ); |
| 892 | $this->lbFactory->waitForReplication(); |
| 893 | } |
| 894 | |
| 895 | // Purge all entries on soft-deleted, expired lists |
| 896 | while ( true ) { |
| 897 | $ids = $this->dbw->newSelectQueryBuilder() |
| 898 | ->select( 'rle_id' ) |
| 899 | ->from( 'reading_list_entry' ) |
| 900 | ->leftJoin( 'reading_list', null, 'rle_rl_id = rl_id' ) |
| 901 | ->where( [ |
| 902 | 'rl_deleted' => 1, |
| 903 | $this->dbw->expr( 'rl_date_updated', '<', $this->dbw->timestamp( $before ) ) |
| 904 | ] ) |
| 905 | ->limit( 1000 ) |
| 906 | ->caller( __METHOD__ )->fetchFieldValues(); |
| 907 | if ( !$ids ) { |
| 908 | break; |
| 909 | } |
| 910 | $this->dbw->newDeleteQueryBuilder() |
| 911 | ->deleteFrom( 'reading_list_entry' ) |
| 912 | ->where( [ 'rle_id' => $ids ] ) |
| 913 | ->caller( __METHOD__ )->execute(); |
| 914 | $this->logger->debug( 'Purged {n} entries', [ 'n' => $this->dbw->affectedRows() ] ); |
| 915 | $this->lbFactory->waitForReplication(); |
| 916 | } |
| 917 | |
| 918 | // Purge all soft-deleted, expired lists |
| 919 | while ( true ) { |
| 920 | $ids = $this->dbw->newSelectQueryBuilder() |
| 921 | ->select( 'rl_id' ) |
| 922 | ->from( 'reading_list' ) |
| 923 | ->where( [ |
| 924 | 'rl_deleted' => 1, |
| 925 | $this->dbw->expr( 'rl_date_updated', '<', $this->dbw->timestamp( $before ) ) |
| 926 | ] ) |
| 927 | ->limit( 1000 ) |
| 928 | ->caller( __METHOD__ )->fetchFieldValues(); |
| 929 | if ( !$ids ) { |
| 930 | break; |
| 931 | } |
| 932 | $this->dbw->newDeleteQueryBuilder() |
| 933 | ->deleteFrom( 'reading_list' ) |
| 934 | ->where( [ 'rl_id' => $ids ] ) |
| 935 | ->caller( __METHOD__ )->execute(); |
| 936 | $this->logger->debug( 'Purged {n} lists', [ 'n' => $this->dbw->affectedRows() ] ); |
| 937 | $this->lbFactory->waitForReplication(); |
| 938 | } |
| 939 | } |
| 940 | |
| 941 | // membership |
| 942 | |
| 943 | /** |
| 944 | * Return all lists which contain a given page. |
| 945 | * @param string $project Project identifier (typically a domain name) |
| 946 | * @param string $title Page title (in localized prefixed DBkey format) |
| 947 | * @param int $limit |
| 948 | * @param int|null $from List ID to continue from (or null to start at the beginning/end). |
| 949 | * |
| 950 | * @throws ReadingListRepositoryException |
| 951 | * @return IResultWrapper<ReadingListRow> |
| 952 | */ |
| 953 | public function getListsByPage( $project, $title, $limit = 1000, $from = null ) { |
| 954 | $this->assertUser(); |
| 955 | $projectId = $this->getProjectId( $project ); |
| 956 | if ( !$projectId ) { |
| 957 | return new FakeResultWrapper( [] ); |
| 958 | } |
| 959 | |
| 960 | $conditions = [ |
| 961 | 'rle_user_id' => $this->userId, |
| 962 | 'rle_rlp_id' => $projectId, |
| 963 | 'rle_title' => $title, |
| 964 | 'rle_deleted' => 0, |
| 965 | 'rl_deleted' => 0 |
| 966 | ]; |
| 967 | |
| 968 | $queryBuilder = $this->dbr->newSelectQueryBuilder() |
| 969 | ->select( array_merge( $this->getListFields(), [ 'rle_id' ] ) ) |
| 970 | ->from( 'reading_list_entry' ) |
| 971 | ->join( 'reading_list', null, 'rl_id = rle_rl_id' ) |
| 972 | ->where( $conditions ) |
| 973 | ->limit( (int)$limit ) |
| 974 | ->caller( __METHOD__ ); |
| 975 | |
| 976 | if ( $from !== null ) { |
| 977 | $queryBuilder->andWhere( |
| 978 | $this->dbr->expr( 'rle_rl_id', '>=', (int)$from ) |
| 979 | ); |
| 980 | } |
| 981 | |
| 982 | // Avoid default list showing on pages > 1, so exclude it and skip order by |
| 983 | if ( $from ) { |
| 984 | $queryBuilder->where( 'rl_is_default = 0' ); |
| 985 | } else { |
| 986 | $queryBuilder->orderBy( 'rl_is_default', 'DESC' ); |
| 987 | } |
| 988 | |
| 989 | $res = $queryBuilder->orderBy( 'rle_rl_id', 'ASC' )->fetchResultSet(); |
| 990 | if ( |
| 991 | $res->numRows() === 0 && |
| 992 | !$this->getDefaultListIdForUser( IDBAccessObject::READ_LATEST ) |
| 993 | ) { |
| 994 | throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); |
| 995 | } |
| 996 | return $res; |
| 997 | } |
| 998 | |
| 999 | /** |
| 1000 | * Recalculate the size of the given list. |
| 1001 | * @param int $id |
| 1002 | * @return bool True if the list needed to be fixed. |
| 1003 | * @throws ReadingListRepositoryException |
| 1004 | */ |
| 1005 | public function fixListSize( $id ) { |
| 1006 | $this->dbw->startAtomic( __METHOD__ ); |
| 1007 | $oldSize = $this->dbw->newSelectQueryBuilder() |
| 1008 | ->select( 'rl_size' ) |
| 1009 | // lock the row to avoid race conditions with purgeOldDeleted() in the update case |
| 1010 | ->forUpdate() |
| 1011 | ->from( 'reading_list' ) |
| 1012 | ->where( [ 'rl_id' => $id ] ) |
| 1013 | ->caller( __METHOD__ )->fetchField(); |
| 1014 | if ( $oldSize === false ) { |
| 1015 | throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); |
| 1016 | } |
| 1017 | |
| 1018 | $count = $this->dbw->newSelectQueryBuilder() |
| 1019 | ->select( 'count(*)' ) |
| 1020 | ->from( 'reading_list_entry' ) |
| 1021 | ->where( [ 'rle_rl_id' => $id, 'rle_deleted' => 0, ] ) |
| 1022 | ->groupBy( 'rle_rl_id' ) |
| 1023 | ->caller( __METHOD__ )->fetchField(); |
| 1024 | $this->dbw->newUpdateQueryBuilder() |
| 1025 | ->update( 'reading_list' ) |
| 1026 | ->set( [ 'rl_size' => $count ] ) |
| 1027 | ->where( [ |
| 1028 | 'rl_id' => $id, |
| 1029 | $this->dbw->expr( 'rl_size', '!=', (int)$count ) |
| 1030 | ] ) |
| 1031 | ->caller( __METHOD__ )->execute(); |
| 1032 | |
| 1033 | // Release the lock when using explicit transactions (called from a long-running script). |
| 1034 | $this->dbw->endAtomic( __METHOD__ ); |
| 1035 | return (bool)$this->dbw->affectedRows(); |
| 1036 | } |
| 1037 | |
| 1038 | // helper methods |
| 1039 | |
| 1040 | /** |
| 1041 | * Get this list of reading_list fields that normally need to be selected. |
| 1042 | * @return array |
| 1043 | */ |
| 1044 | private function getListFields() { |
| 1045 | return [ |
| 1046 | 'rl_id', |
| 1047 | // returning rl_user_id is pointless as lists are only available to the owner |
| 1048 | 'rl_is_default', |
| 1049 | 'rl_name', |
| 1050 | 'rl_description', |
| 1051 | 'rl_date_created', |
| 1052 | 'rl_date_updated', |
| 1053 | 'rl_size', |
| 1054 | 'rl_deleted', |
| 1055 | ]; |
| 1056 | } |
| 1057 | |
| 1058 | /** |
| 1059 | * Get this list of reading_list_entry fields that normally need to be selected. |
| 1060 | * Can only be used with queries that join on reading_list_project. |
| 1061 | * @return array |
| 1062 | */ |
| 1063 | private function getListEntryFields() { |
| 1064 | return [ |
| 1065 | 'rle_id', |
| 1066 | 'rle_rl_id', |
| 1067 | // returning rle_user_id is pointless as lists are only available to the owner |
| 1068 | // skip rle_rlp_id, it's only needed for the join |
| 1069 | 'rlp_project', |
| 1070 | 'rle_title', |
| 1071 | 'rle_date_created', |
| 1072 | 'rle_date_updated', |
| 1073 | 'rle_deleted', |
| 1074 | ]; |
| 1075 | } |
| 1076 | |
| 1077 | /** |
| 1078 | * Require the user to be specified. |
| 1079 | * @throws ReadingListRepositoryException |
| 1080 | */ |
| 1081 | private function assertUser() { |
| 1082 | if ( !is_int( $this->userId ) ) { |
| 1083 | throw new ReadingListRepositoryException( 'readinglists-db-error-user-required' ); |
| 1084 | } |
| 1085 | } |
| 1086 | |
| 1087 | /** |
| 1088 | * Ensures that the value to be written to the database does not exceed the DB field length. |
| 1089 | * @param string $field Field name. |
| 1090 | * @param string $value Value to write. |
| 1091 | * @throws ReadingListRepositoryException |
| 1092 | */ |
| 1093 | private function assertFieldLength( $field, $value ) { |
| 1094 | if ( !isset( self::$fieldLength[$field] ) ) { |
| 1095 | throw new LogicException( 'Tried to assert length for invalid field ' . $field ); |
| 1096 | } |
| 1097 | if ( strlen( $value ?? '' ) > self::$fieldLength[$field] ) { |
| 1098 | throw new ReadingListRepositoryException( 'readinglists-db-error-too-long', |
| 1099 | [ $field, self::$fieldLength[$field] ] ); |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | /** |
| 1104 | * Validate sort parameters. |
| 1105 | * @param string $tablePrefix 'rl' or 'rle', depending on whether we are sorting lists or entries. |
| 1106 | * @param string $sortBy |
| 1107 | * @param string $sortDir |
| 1108 | * @param int $limit |
| 1109 | * @param array|null $from [sortby-value, id] |
| 1110 | * @return array [ conditions, options ] Merge these into the corresponding IDatabase::select |
| 1111 | * parameters. |
| 1112 | */ |
| 1113 | private function processSort( $tablePrefix, $sortBy, $sortDir, $limit, $from ) { |
| 1114 | if ( !in_array( $sortBy, [ self::SORT_BY_NAME, self::SORT_BY_UPDATED ], true ) ) { |
| 1115 | throw new LogicException( 'Invalid $sortBy parameter: ' . $sortBy ); |
| 1116 | } |
| 1117 | if ( !in_array( $sortDir, [ self::SORT_DIR_ASC, self::SORT_DIR_DESC ], true ) ) { |
| 1118 | throw new LogicException( 'Invalid $sortDir parameter: ' . $sortDir ); |
| 1119 | } |
| 1120 | if ( is_array( $from ) ) { |
| 1121 | if ( count( $from ) !== 2 || !is_string( $from[0] ) || !is_numeric( $from[1] ) ) { |
| 1122 | throw new LogicException( 'Invalid $from parameter' ); |
| 1123 | } |
| 1124 | } elseif ( $from !== null ) { |
| 1125 | throw new LogicException( 'Invalid $from parameter type: ' . get_debug_type( $from ) ); |
| 1126 | } |
| 1127 | |
| 1128 | if ( $tablePrefix === 'rl' ) { |
| 1129 | $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 'rl_name' : 'rl_date_updated'; |
| 1130 | } else { |
| 1131 | $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 'rle_title' : 'rle_date_updated'; |
| 1132 | } |
| 1133 | $idField = "{$tablePrefix}_id"; |
| 1134 | $conditions = []; |
| 1135 | $options = [ |
| 1136 | 'ORDER BY' => [ "$mainField $sortDir" ], |
| 1137 | 'LIMIT' => (int)$limit, |
| 1138 | ]; |
| 1139 | // List names are unique and need no tiebreaker. |
| 1140 | if ( $sortBy !== self::SORT_BY_NAME || $tablePrefix !== 'rl' ) { |
| 1141 | $options['ORDER BY'][] = "$idField $sortDir"; |
| 1142 | } |
| 1143 | |
| 1144 | if ( $from !== null ) { |
| 1145 | $op = ( $sortDir === self::SORT_DIR_ASC ) ? '>' : '<'; |
| 1146 | $safeFromMain = ( $sortBy === self::SORT_BY_NAME ) |
| 1147 | ? $from[0] |
| 1148 | : $this->dbr->timestamp( $from[0] ); |
| 1149 | $safeFromId = (int)$from[1]; |
| 1150 | // List names are unique and need no tiebreaker. |
| 1151 | if ( $sortBy === self::SORT_BY_NAME && $tablePrefix === 'rl' ) { |
| 1152 | $condition = $this->dbw->expr( $mainField, "$op=", $safeFromMain ); |
| 1153 | } else { |
| 1154 | $condition = $this->dbr->buildComparison( "$op=", [ |
| 1155 | $mainField => $safeFromMain, |
| 1156 | $idField => $safeFromId |
| 1157 | ] ); |
| 1158 | } |
| 1159 | $conditions[] = $condition; |
| 1160 | } |
| 1161 | |
| 1162 | // note: $conditions will be array_merge-d so it should not contain non-numeric keys |
| 1163 | return [ $conditions, $options ]; |
| 1164 | } |
| 1165 | |
| 1166 | /** |
| 1167 | * Returns the number of (non-deleted) lists of the current user. |
| 1168 | * @param int $flags IDBAccessObject flags |
| 1169 | * @return int |
| 1170 | * @throws ReadingListRepositoryException |
| 1171 | */ |
| 1172 | private function getListCount( $flags = 0 ) { |
| 1173 | $this->assertUser(); |
| 1174 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
| 1175 | $db = $this->dbw; |
| 1176 | } else { |
| 1177 | $db = $this->dbr; |
| 1178 | } |
| 1179 | return $db->newSelectQueryBuilder() |
| 1180 | ->select( '1' ) |
| 1181 | ->from( 'reading_list' ) |
| 1182 | ->where( [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0, ] ) |
| 1183 | ->recency( $flags ) |
| 1184 | ->caller( __METHOD__ )->fetchRowCount(); |
| 1185 | } |
| 1186 | |
| 1187 | /** |
| 1188 | * Look up a project ID. |
| 1189 | * @param string $project |
| 1190 | * @return int|null |
| 1191 | */ |
| 1192 | private function getProjectId( $project ) { |
| 1193 | if ( $project === '@local' ) { |
| 1194 | // Support for "@local" is provided primarily for the benefit |
| 1195 | // of Mocha tests, so they don't have to know the actual project |
| 1196 | // URL. |
| 1197 | $project = $this->getLocalProject(); |
| 1198 | } |
| 1199 | |
| 1200 | $id = $this->dbr->newSelectQueryBuilder() |
| 1201 | ->select( 'rlp_id' ) |
| 1202 | ->from( 'reading_list_project' ) |
| 1203 | ->where( [ 'rlp_project' => $project ] ) |
| 1204 | ->caller( __METHOD__ )->fetchField(); |
| 1205 | return $id === false ? null : (int)$id; |
| 1206 | } |
| 1207 | |
| 1208 | /** |
| 1209 | * Returns the number of (non-deleted) list entries of the given list. |
| 1210 | * Verifying that the list is valid is caller's responsibility. |
| 1211 | * @param int $id List id |
| 1212 | * @param int $flags IDBAccessObject flags |
| 1213 | * @return int |
| 1214 | * @throws ReadingListRepositoryException |
| 1215 | */ |
| 1216 | private function getEntryCount( $id, $flags = 0 ) { |
| 1217 | $this->assertUser(); |
| 1218 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
| 1219 | $db = $this->dbw; |
| 1220 | } else { |
| 1221 | $db = $this->dbr; |
| 1222 | } |
| 1223 | return (int)$db->newSelectQueryBuilder() |
| 1224 | ->select( 'rl_size' ) |
| 1225 | ->from( 'reading_list' ) |
| 1226 | ->where( [ 'rl_id' => $id ] ) |
| 1227 | ->recency( $flags ) |
| 1228 | ->caller( __METHOD__ )->fetchField(); |
| 1229 | } |
| 1230 | |
| 1231 | /** |
| 1232 | * Confirm that at least one project exists. Create one if necessary. |
| 1233 | * Projects are global to a wiki/wiki farm. Wiki farms using the SiteMatrix |
| 1234 | * extension should initialize their projects via maintenance script. |
| 1235 | * |
| 1236 | * @return bool True if projects were initialized. |
| 1237 | */ |
| 1238 | public function initializeProjectIfNeeded(): bool { |
| 1239 | if ( $this->hasProjects() ) { |
| 1240 | return false; |
| 1241 | } |
| 1242 | |
| 1243 | $project = $this->getLocalProject(); |
| 1244 | if ( !$project ) { |
| 1245 | throw new LogicException( 'Unable to load canonical url for project initialization' ); |
| 1246 | } |
| 1247 | |
| 1248 | $this->dbw->newInsertQueryBuilder() |
| 1249 | ->insertInto( 'reading_list_project' ) |
| 1250 | ->row( [ |
| 1251 | 'rlp_project' => $project, |
| 1252 | ] ) |
| 1253 | ->caller( __METHOD__ )->execute(); |
| 1254 | |
| 1255 | return true; |
| 1256 | } |
| 1257 | |
| 1258 | /** |
| 1259 | * Whether any projects have been registered for use with reading lists. |
| 1260 | * If no projects have been registered, it will not be possible to |
| 1261 | * add entries to lists. |
| 1262 | */ |
| 1263 | public function hasProjects(): bool { |
| 1264 | $count = $this->dbr->newSelectQueryBuilder() |
| 1265 | ->select( 'COUNT(*)' )->from( |
| 1266 | 'reading_list_project' |
| 1267 | )->caller( __METHOD__ )->fetchField(); |
| 1268 | |
| 1269 | return $count > 0; |
| 1270 | } |
| 1271 | |
| 1272 | /** |
| 1273 | * @return string |
| 1274 | */ |
| 1275 | private function getLocalProject(): string { |
| 1276 | $url = MediaWikiServices::getInstance()->getUrlUtils()->getCanonicalServer(); |
| 1277 | if ( $url === '' ) { |
| 1278 | return ''; |
| 1279 | } |
| 1280 | |
| 1281 | $parts = MediaWikiServices::getInstance()->getUrlUtils()->parse( $url ); |
| 1282 | $parts['port'] = null; |
| 1283 | $project = MediaWikiServices::getInstance()->getUrlUtils()->assemble( $parts ); |
| 1284 | |
| 1285 | return $project; |
| 1286 | } |
| 1287 | } |