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