Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.97% covered (warning)
87.97%
585 / 665
38.71% covered (danger)
38.71%
12 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReadingListRepository
87.97% covered (warning)
87.97%
585 / 665
38.71% covered (danger)
38.71%
12 / 31
147.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setLimits
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setupForUser
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
4
 teardownForUser
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
3.05
 getDefaultListIdForUser
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 selectValidList
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 addList
89.83% covered (warning)
89.83%
53 / 59
0.00% covered (danger)
0.00%
0 / 1
7.05
 getAllLists
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 updateList
69.57% covered (warning)
69.57%
32 / 46
0.00% covered (danger)
0.00%
0 / 1
11.28
 deleteList
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
3.06
 addListEntry
93.98% covered (success)
93.98%
78 / 83
0.00% covered (danger)
0.00%
0 / 1
9.02
 getListEntries
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
6
 getAllListEntries
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 deleteListEntry
88.64% covered (warning)
88.64%
39 / 44
0.00% covered (danger)
0.00%
0 / 1
6.05
 getListsByDateUpdated
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getListEntriesByDateUpdated
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 purgeOldDeleted
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
7
 getListsByPage
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
6
 fixListSize
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 getListFields
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getListEntryFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 assertUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertFieldLength
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 processSort
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
18.53
 getListCount
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 getProjectId
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getEntryCount
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 initializeProjectIfNeeded
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 hasProjects
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getLocalProject
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3namespace MediaWiki\Extension\ReadingLists;
4
5use LogicException;
6use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRow;
7use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRowWithMergeFlag;
8use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow;
9use MediaWiki\Extension\ReadingLists\Doc\ReadingListRowWithMergeFlag;
10use MediaWiki\MediaWikiServices;
11use Psr\Log\LoggerAwareInterface;
12use Psr\Log\LoggerInterface;
13use Psr\Log\NullLogger;
14use Wikimedia\Rdbms\FakeResultWrapper;
15use Wikimedia\Rdbms\IDatabase;
16use Wikimedia\Rdbms\IDBAccessObject;
17use Wikimedia\Rdbms\IReadableDatabase;
18use Wikimedia\Rdbms\IResultWrapper;
19use Wikimedia\Rdbms\LBFactory;
20use 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 */
36class 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}