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