jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/393925 )

Change subject: Sort lists and entries by name and last updated timestamp
......................................................................


Sort lists and entries by name and last updated timestamp

Add sort options to the read methods:
* by name/updated for getAllLists, getListEntries and
  getListsByDateUpdated
* getListEntriesByDateUpdated is always sorted by update time
  (to do otherwise would require an extra index and there is
  no use case for it)
* getListsByPage has no human-comprehensible sorting (results
  are assumed to be small).

Ensure stable paging by using the primary key as secondary sortkey
everywhere.

Improve performance by aligning indexes with sort options
(and getting rid of the old offset-based paging):
* getAllLists, getListsByDateUpdated, getListEntries, and
  purgeOldDeleted have a matching index (for both sort methods,
  in the case of the first three);
* getListEntriesByDateUpdated has an almost matching index (both
  tables have to be filtered for the deleted flag so no way to cram
  everything in a single index).
* getListsByPage is an efficient select with rle_user_project_title
  (use index to find all lists with that page, then skip where
  the list or the page is deleted) which then has to be sorted in
  a temporary table for the GROUP BY / ORDER BY. Items can appear
  only once per list (deleted flag notwithstanding) and list count
  is capped at 100 so this is a filesort on 100 rows max and the
  number of scanned rows is the number of lists containing the page
  (could be >100 due to deletions).

May have gone a bit overboard with the indexes but in previous
code review there was a preference for avoiding filesorts even
on smallish result sets and it takes this many to accomplish that.

Bug: T180402
Bug: T182053
Change-Id: Ia04a6c42ed7eca2a1c49d6ef109f0359db06ccd4
---
M extension.json
M i18n/en.json
M i18n/qqq.json
A sql/patches/03-add-sort-indexes.sql
M sql/readinglists.sql
M src/Api/ApiQueryReadingListEntries.php
M src/Api/ApiQueryReadingLists.php
A src/Api/ApiQueryTrait.php
M src/HookHandler.php
M src/ReadingListRepository.php
M tests/src/ReadingListRepositoryTest.php
11 files changed, 701 insertions(+), 287 deletions(-)

Approvals:
  jenkins-bot: Verified
  Mholloway: Looks good to me, approved



diff --git a/extension.json b/extension.json
index 1f5e374..84ceb09 100644
--- a/extension.json
+++ b/extension.json
@@ -17,6 +17,7 @@
                "MediaWiki\\Extensions\\ReadingLists\\ReverseInterwikiLookup": 
"src/ReverseInterwikiLookup.php",
                
"MediaWiki\\Extensions\\ReadingLists\\Api\\ApiQueryReadingListEntries": 
"src/Api/ApiQueryReadingListEntries.php",
                
"MediaWiki\\Extensions\\ReadingLists\\Api\\ApiQueryReadingLists": 
"src/Api/ApiQueryReadingLists.php",
+               "MediaWiki\\Extensions\\ReadingLists\\Api\\ApiQueryTrait": 
"src/Api/ApiQueryTrait.php",
                "MediaWiki\\Extensions\\ReadingLists\\Api\\ApiReadingLists": 
"src/Api/ApiReadingLists.php",
                
"MediaWiki\\Extensions\\ReadingLists\\Api\\ApiReadingListsCreateEntry": 
"src/Api/ApiReadingListsCreateEntry.php",
                
"MediaWiki\\Extensions\\ReadingLists\\Api\\ApiReadingListsCreate": 
"src/Api/ApiReadingListsCreate.php",
diff --git a/i18n/en.json b/i18n/en.json
index 82e84ff..f3acfe8 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -24,11 +24,16 @@
        "readinglists-db-error-no-such-project": "'$1' is not a recognized 
project.",
        "readinglists-apierror-project-title-param": "<var>$1project</var> and 
<var>$1title</var> must be used together.",
        "readinglists-apierror-too-old": "Timestamps passed to 
<var>$1changedsince</var> cannot be older than <kbd>$2</kbd>.",
+       "readinglists-apierror-invalidsort-notbyname": "The <kbd>name</kbd> 
sort option cannot be used together with <var>$1changedsince</var>.",
        "apihelp-query+readinglists-summary": "List or filter the user's 
reading lists and show metadata about them.",
        "apihelp-query+readinglists-extended-description": "This module has 
three modes of operation. With the <var>$1changedsince</var> parameter, it 
returns all lists of the current user which have been changed since the given 
date. (This is meant for device sync and, unlike the other modes, includes 
deleted lists. Only changes to list metadata are considered, not changes to 
list items.) With the <var>$1project</var> and <var>$1title</var> parameters, 
it returns all lists that include that page. Without any of those parameters, 
it returns all lists.",
        "apihelp-query+readinglists-param-changedsince": "Show lists that have 
been changed since this timestamp. Must be after <kbd>$1</kbd>.",
        "apihelp-query+readinglists-param-project": "Project of the page to 
filter on. Must be used together with <var>$1title</var>.",
        "apihelp-query+readinglists-param-title": "Title of the page to filter 
on. Must be used together with <var>$1project</var>.",
+       "apihelp-query+readinglists-param-sort": "Property to sort by. Ignored 
when <var>$1project</var> and <var>$1title</var> is set (results are returned 
in DB order). Defaults to <kbd>updated</kbd> when <var>$1changedsince</var> is 
set, and to <kbd>name</kbd> otherwise.",
+       "apihelp-query+readinglists-paramvalue-sort-name": "List name. (Sorting 
is by binary value; e.g. any uppercase ASCII character will sort before any 
lowercase one.)",
+       "apihelp-query+readinglists-paramvalue-sort-updated": "Last update 
timestamp. (Updates include list metadata changes but not changes to list 
items.)",
+       "apihelp-query+readinglists-param-dir": "Sort direction: 
<kbd>ascending</kbd> (A to Z, oldest to newest) or <kbd>descending</kbd>. 
Ignored when <var>$1project</var> and <var>$1title</var> is set. ",
        "apihelp-query+readinglists-param-limit": "Number of result items to 
return.",
        "apihelp-query+readinglists-example-1": "Get the reading lists of the 
current user.",
        "apihelp-query+readinglists-example-2": "Get the reading lists of the 
current user which have changed since <kbd>2013-01-01T00:00:00Z</kbd>.",
@@ -37,6 +42,10 @@
        "apihelp-query+readinglistentries-extended-description": "This module 
has two modes of operation. With the <var>$1lists</var> parameter, it returns 
the pages in the given list(s). With the <var>$1changedsince</var> parameter, 
it returns all list entries from any list of the current user which have been 
changed since the given date. (This is meant for device sync and, unlike the 
other modes, includes deleted entries, although not entries of deleted lists.)",
        "apihelp-query+readinglistentries-param-lists": "The list IDs for which 
to return pages.",
        "apihelp-query+readinglistentries-param-changedsince": "Show list 
entries that have been changed since this timestamp. Must be after 
<kbd>$1</kbd>.",
+       "apihelp-query+readinglistentries-param-sort": "Property to sort by. 
<kbd>name</kbd> cannot be used together with <var>$1changedsince</var>. 
Defaults to <kbd>updated</kbd> when <var>$1changedsince</var> is set, and to 
<kbd>name</kbd> otherwise.",
+       "apihelp-query+readinglistentries-paramvalue-sort-name": "Article 
title. (Project name is ignored. Sorting is by binary value; e.g. any uppercase 
ASCII character will sort before any lowercase one.)",
+       "apihelp-query+readinglistentries-paramvalue-sort-updated": "Last 
update timestamp.",
+       "apihelp-query+readinglistentries-param-dir": "Sort direction: 
<kbd>ascending</kbd> (A to Z, oldest to newest) or <kbd>descending</kbd>.",
        "apihelp-query+readinglistentries-param-limit": "Number of result items 
to return.",
        "apihelp-query+readinglistentries-example-1": "Get the pages from the 
reading lists with ID <kbd>10</kbd>, <kbd>11</kbd> and <kbd>12</kbd>.",
        "apihelp-query+readinglistentries-example-2": "Get the list entries of 
the current user which have changed since <kbd>2013-01-01T00:00:00Z</kbd>.",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index af4ad6a..b79480e 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -25,11 +25,16 @@
        "readinglists-db-error-no-such-project": "Error message used when the 
user tries to add a new list entry, but the project does not match any of the 
known ones.\n\nParameters:\n* $1 - the project.",
        "readinglists-apierror-project-title-param": "{{doc-apierror}}\n$1 is 
the module prefix.",
        "readinglists-apierror-too-old": "{{doc-apierror}}\n$1 is the module 
prefix, $2 is the expiry date for deleted lists/entries.",
+       "readinglists-apierror-invalidsort-notbyname": "{{doc-apierror}}\n$1 is 
the module prefix.",
        "apihelp-query+readinglists-summary": 
"{{doc-apihelp-summary|query+readinglists}}",
        "apihelp-query+readinglists-extended-description": 
"{{doc-apihelp-extended-description|query+readinglists}}",
        "apihelp-query+readinglists-param-changedsince": 
"{{doc-apihelp-param|query+readinglists|changedsince|paramstart=2|params=* $1 - 
Oldest allowed value}}",
        "apihelp-query+readinglists-param-project": 
"{{doc-apihelp-param|query+readinglists|project}}",
        "apihelp-query+readinglists-param-title": 
"{{doc-apihelp-param|query+readinglists|title}}",
+       "apihelp-query+readinglists-param-sort": 
"{{doc-apihelp-param|query+readinglists|sort}}",
+       "apihelp-query+readinglists-paramvalue-sort-name": 
"{{doc-apihelp-paramvalue|query+readinglists|sort|name}}",
+       "apihelp-query+readinglists-paramvalue-sort-updated": 
"{{doc-apihelp-paramvalue|query+readinglists|sort|updated}}",
+       "apihelp-query+readinglists-param-dir": 
"{{doc-apihelp-param|query+readinglists|dir}}",
        "apihelp-query+readinglists-param-limit": 
"{{doc-apihelp-param|query+readinglists|limit}}",
        "apihelp-query+readinglists-example-1": 
"{{doc-apihelp-example|query+readinglists}}",
        "apihelp-query+readinglists-example-2": 
"{{doc-apihelp-example|query+readinglists}}",
@@ -38,6 +43,10 @@
        "apihelp-query+readinglistentries-extended-description": 
"{{doc-apihelp-extended-description|query+readinglistentries}}",
        "apihelp-query+readinglistentries-param-lists": 
"{{doc-apihelp-param|query+readinglistentries|lists}}",
        "apihelp-query+readinglistentries-param-changedsince": 
"{{doc-apihelp-param|query+readinglistentries|changedsince|paramstart=2|params=*
 $1 - Oldest allowed value}}",
+       "apihelp-query+readinglistentries-param-sort": 
"{{doc-apihelp-param|query+readinglistentries|sort}}",
+       "apihelp-query+readinglistentries-paramvalue-sort-name": 
"{{doc-apihelp-paramvalue|query+readinglistentries|sort|name}}",
+       "apihelp-query+readinglistentries-paramvalue-sort-updated": 
"{{doc-apihelp-paramvalue|query+readinglistentries|sort|updated}}",
+       "apihelp-query+readinglistentries-param-dir": 
"{{doc-apihelp-param|query+readinglistentries|dir}}",
        "apihelp-query+readinglistentries-param-limit": 
"{{doc-apihelp-param|query+readinglistentries|limit}}",
        "apihelp-query+readinglistentries-example-1": 
"{{doc-apihelp-example|query+readinglistentries}}",
        "apihelp-query+readinglistentries-example-2": 
"{{doc-apihelp-example|query+readinglistentries}}",
diff --git a/sql/patches/03-add-sort-indexes.sql 
b/sql/patches/03-add-sort-indexes.sql
new file mode 100644
index 0000000..e840caa
--- /dev/null
+++ b/sql/patches/03-add-sort-indexes.sql
@@ -0,0 +1,15 @@
+DROP INDEX /*i*/rl_user_updated ON /*_*/reading_list;
+DROP INDEX /*i*/rl_user_deleted ON /*_*/reading_list;
+DROP INDEX /*i*/rle_list_updated ON /*_*/reading_list_entry;
+DROP INDEX /*i*/rle_user_project_title ON /*_*/reading_list_entry;;
+
+CREATE INDEX /*i*/rl_user_default ON /*_*/reading_list (rl_user_id, 
rl_is_default);
+CREATE UNIQUE INDEX /*i*/rl_user_deleted_name_id ON /*_*/reading_list 
(rl_user_id, rl_deleted, rl_name, rl_id);
+CREATE UNIQUE INDEX /*i*/rl_user_deleted_updated_id ON /*_*/reading_list 
(rl_user_id, rl_deleted, rl_date_updated, rl_id);
+CREATE INDEX /*i*/rl_deleted_updated ON /*_*/reading_list (rl_deleted, 
rl_date_updated);
+
+CREATE UNIQUE INDEX /*i*/rle_list_deleted_title_id ON /*_*/reading_list_entry 
(rle_rl_id, rle_deleted, rle_title, rle_id);
+CREATE UNIQUE INDEX /*i*/rle_list_deleted_updated_id ON 
/*_*/reading_list_entry (rle_rl_id, rle_deleted, rle_date_updated, rle_id);
+CREATE UNIQUE INDEX /*i*/rle_user_updated_id ON /*_*/reading_list_entry 
(rle_user_id, rle_date_updated, rle_id);
+CREATE UNIQUE INDEX /*i*/rle_user_project_title ON /*_*/reading_list_entry 
(rle_user_id, rle_rlp_id, rle_title, rle_rl_id);
+CREATE INDEX /*i*/rle_deleted_updated ON /*_*/reading_list_entry (rle_deleted, 
rle_date_updated);
diff --git a/sql/readinglists.sql b/sql/readinglists.sql
index 055c135..11ced2e 100644
--- a/sql/readinglists.sql
+++ b/sql/readinglists.sql
@@ -29,11 +29,21 @@
     -- Lists will be hard-deleted eventually but kept around for a while for 
sync.
     rl_deleted TINYINT NOT NULL DEFAULT 0
 ) /*$wgDBTableOptions*/;
--- For syncing lists that changed since a given date.
-CREATE INDEX /*i*/rl_user_updated ON /*_*/reading_list (rl_user_id, 
rl_date_updated);
--- For getting all non-deleted items.
-CREATE INDEX /*i*/rl_user_deleted ON /*_*/reading_list (rl_user_id, 
rl_deleted);
--- TODO date_updated + deleted for cleanup?
+-- For isSetupForUser() which is called a lot and used for row locks. Will 
only ever be used
+-- with rl_is_default = 1 so effectively a unique index.
+CREATE INDEX /*i*/rl_user_default ON /*_*/reading_list (rl_user_id, 
rl_is_default);
+-- For querying lists of a user by name (and then id as a tiebreaker). Covers 
getAllLists() with
+-- SORT_BY_NAME.
+CREATE UNIQUE INDEX /*i*/rl_user_deleted_name_id ON /*_*/reading_list 
(rl_user_id, rl_deleted, rl_name, rl_id);
+-- For querying lists of a user by last updated timestamp (and then id as a 
tiebreaker). Covers
+-- getAllLists() with SORT_BY_UPDATED.
+CREATE UNIQUE INDEX /*i*/rl_user_deleted_updated_id ON /*_*/reading_list 
(rl_user_id, rl_deleted, rl_date_updated, rl_id);
+-- Like the previous two, except for getListsByDateUpdated() which does not 
have rl_deleted=0.
+-- TODO this is the lazy option, the previous indexes could be made to work if 
the sort matched the condition.
+CREATE UNIQUE INDEX /*i*/rl_user_name_id ON /*_*/reading_list (rl_user_id, 
rl_name, rl_id);
+CREATE UNIQUE INDEX /*i*/rl_user_updated_id ON /*_*/reading_list (rl_user_id, 
rl_deleted, rl_date_updated, rl_id);
+-- For getting all deleted items older than a given date. Covers 
purgeOldDeleted().
+CREATE INDEX /*i*/rl_deleted_updated ON /*_*/reading_list (rl_deleted, 
rl_date_updated);
 
 -- List items.
 CREATE TABLE /*_*/reading_list_entry (
@@ -56,12 +66,28 @@
     -- Entries will be hard-deleted eventually but kept around for a while for 
sync.
     rle_deleted TINYINT NOT NULL DEFAULT 0
 ) /*$wgDBTableOptions*/;
--- For getting all entries in a list and for syncing list entries that changed 
since a given date.
-CREATE INDEX /*i*/rle_list_updated ON /*_*/reading_list_entry (rle_rl_id, 
rle_date_updated);
--- For getting all lists of a given user which contain a specified page.
-CREATE INDEX /*i*/rle_user_project_title ON /*_*/reading_list_entry 
(rle_user_id, rle_rlp_id, rle_title);
 -- For ensuring there are no duplicate pages on a single list.
+-- (What we actually need is "no duplicate non-deleted items" but there is no 
way to turn that
+-- into an index condition so the software will work around it by repurposing 
deleted items as needed.)
 CREATE UNIQUE INDEX /*i*/rle_list_project_title ON /*_*/reading_list_entry 
(rle_rl_id, rle_rlp_id, rle_title);
+-- For querying list entries in a given list by title (and then id as a 
tiebreaker). Covers
+-- getListEntries() with SORT_BY_NAME.
+CREATE UNIQUE INDEX /*i*/rle_list_deleted_title_id ON /*_*/reading_list_entry 
(rle_rl_id, rle_deleted, rle_title, rle_id);
+-- For querying list entries in a given list by last updated timestamp (and 
then id as a tiebreaker).
+-- Covers getListEntries() with SORT_BY_UPDATED.
+CREATE UNIQUE INDEX /*i*/rle_list_deleted_updated_id ON 
/*_*/reading_list_entry (rle_rl_id, rle_deleted, rle_date_updated, rle_id);
+-- For querying all list entries of a given user by last updated timestamp 
(and then id as a
+-- tiebreaker). Covers getListEntriesByDateUpdated() (almost; the results 
still have to be
+-- filtered on rl_deleted).
+CREATE UNIQUE INDEX /*i*/rle_user_updated_id ON /*_*/reading_list_entry 
(rle_user_id, rle_date_updated, rle_id);
+-- For getting all lists of a given user which contain a specified page. 
Covers getListsByPage().
+-- rle_rl_id is included to ensure consistent sorting within a fully covered 
query (the assumption
+-- being that result sets for this query will typically be small enough that 
we don't care about
+-- server-side sorting by name etc; if we do end up with an anomalously huge 
resultset, performance
+-- is preferred over returning the results in order.)
+CREATE UNIQUE INDEX /*i*/rle_user_project_title ON /*_*/reading_list_entry 
(rle_user_id, rle_rlp_id, rle_title, rle_rl_id);
+-- For getting all deleted items older than a given date. Covers 
purgeOldDeleted().
+CREATE INDEX /*i*/rle_deleted_updated ON /*_*/reading_list_entry (rle_deleted, 
rle_date_updated);
 
 -- Table for storing projects (domains) efficiently.
 CREATE TABLE /*_*/reading_list_project (
diff --git a/src/Api/ApiQueryReadingListEntries.php 
b/src/Api/ApiQueryReadingListEntries.php
index eadac97..34aba86 100644
--- a/src/Api/ApiQueryReadingListEntries.php
+++ b/src/Api/ApiQueryReadingListEntries.php
@@ -17,21 +17,7 @@
 class ApiQueryReadingListEntries extends ApiQueryGeneratorBase {
 
        use ApiTrait;
-
-       /**
-        * Return all entries of the given list(s).
-        * Intended for initial copy of data to a new device, or for devices 
which have information
-        * that's too outdated for normal sync. Might also be useful for 
devices with limited storage
-        * capacity, such as web clients.
-        */
-       const MODE_ALL = 'all';
-
-       /**
-        * Return list entries (from any list of the user) which have been 
changed (or deleted) recently.
-        * Intended for syncing updates to a device which has an older snapshot 
of the data.
-        * "Recently" is defined by the changedsince parameter.
-        */
-       const MODE_CHANGES = 'changes';
+       use ApiQueryTrait;
 
        /** @var string API module prefix */
        private static $prefix = 'rle';
@@ -74,13 +60,26 @@
 
                $lists = $this->getParameter( 'lists' );
                $changedSince = $this->getParameter( 'changedsince' );
+               $sort = $this->getParameter( 'sort' );
+               $dir = $this->getParameter( 'dir' );
                $limit = $this->getParameter( 'limit' );
-               $offset = $this->getParameter( 'continue' );
+               $continue = $this->getParameter( 'continue' );
 
-               $mode = $changedSince !== null ? self::MODE_CHANGES : 
self::MODE_ALL;
+               $mode = $changedSince !== null ? self::$MODE_CHANGES : 
self::$MODE_ALL;
+               if ( $sort === null ) {
+                       $sort = ( $mode === self::$MODE_CHANGES ) ? 'updated' : 
'name';
+               }
+               if ( $mode === self::$MODE_CHANGES && $sort === 'name' ) {
+                       // We don't have the right DB index for this. Wouldn't 
make much sense anyways.
+                       $errorMessage = $this->msg( 
'readinglists-apierror-invalidsort-notbyname', static::$prefix );
+                       $this->dieWithError( $errorMessage, 'invalidparammix' );
+               }
+               $sort = self::$sortParamMap[$sort];
+               $dir = self::$sortParamMap[$dir];
+               $continue = $this->decodeContinuationParameter( $continue, 
$mode, $sort );
 
                $this->requireOnlyOneParameter( $this->extractRequestParams(), 
'lists', 'changedsince' );
-               if ( $mode === self::MODE_CHANGES ) {
+               if ( $mode === self::$MODE_CHANGES ) {
                        $expiry = Utils::getDeletedExpiry();
                        if ( $changedSince < $expiry ) {
                                $errorMessage = $this->msg( 
'readinglists-apierror-too-old', static::$prefix,
@@ -94,23 +93,29 @@
                $result->addIndexedTagName( $path, 'entry' );
 
                $repository = $this->getReadingListRepository( $this->getUser() 
);
-               if ( $mode === self::MODE_CHANGES ) {
-                       $res = $repository->getListEntriesByDateUpdated( 
$changedSince, $limit + 1, $offset );
+               if ( $mode === self::$MODE_CHANGES ) {
+                       $res = $repository->getListEntriesByDateUpdated( 
$changedSince, $dir, $limit + 1, $continue );
                } else {
-                       $res = $repository->getListEntries( $lists, $limit + 1, 
$offset );
+                       $res = $repository->getListEntries( $lists, $sort, 
$dir, $limit + 1, $continue );
                }
                $titles = [];
-               $resultOffset = 0;
                $fits = true;
-               foreach ( $res as $row ) {
-                       $isLastRow = ( $resultOffset === $res->numRows() - 1 );
+               foreach ( $res as $i => $row ) {
+                       $item = $this->getResultItem( $row, $mode );
+                       if ( $i > $limit ) {
+                               // $i is 1-based so this means we reached the 
extra row.
+                               $this->setContinueEnumParameter( 'continue',
+                                       $this->encodeContinuationParameter( 
$item, $mode, $sort ) );
+                               break;
+                       }
                        if ( $resultPageSet ) {
                                $titles[] = $this->getResultTitle( $row );
                        } else {
-                               $fits = $result->addValue( $path, null, 
$this->getResultItem( $row, $mode ) );
+                               $fits = $result->addValue( $path, null, $item );
                        }
-                       if ( !$fits || ++$resultOffset >= $limit && !$isLastRow 
) {
-                               $this->setContinueEnumParameter( 'continue', 
$offset + $resultOffset );
+                       if ( !$fits ) {
+                               $this->setContinueEnumParameter( 'continue',
+                                       $this->encodeContinuationParameter( 
$item, $mode, $sort ) );
                                break;
                        }
                }
@@ -134,19 +139,7 @@
                                self::PARAM_HELP_MSG => $this->msg( 
'apihelp-query+readinglistentries-param-changedsince',
                                        wfTimestamp( TS_ISO_8601, 
Utils::getDeletedExpiry() ) ),
                        ],
-                       'limit' => [
-                               self::PARAM_DFLT => 10,
-                               self::PARAM_TYPE => 'limit',
-                               self::PARAM_MIN => 1,
-                               self::PARAM_MAX => self::LIMIT_BIG1,
-                               self::PARAM_MAX2 => self::LIMIT_BIG2,
-                       ],
-                       'continue' => [
-                               self::PARAM_TYPE => 'integer',
-                               self::PARAM_DFLT => 0,
-                               self::PARAM_HELP_MSG => 
'api-help-param-continue',
-                       ],
-               ];
+               ] + $this->getAllowedSortParams();
        }
 
        /**
@@ -204,7 +197,7 @@
                        'title' => $row->rle_title,
                        'created' => wfTimestamp( TS_ISO_8601, 
$row->rle_date_created ),
                        'updated' => wfTimestamp( TS_ISO_8601, 
$row->rle_date_updated ),
-          ] + ( $mode === self::MODE_CHANGES ? [ 'deleted' => 
(bool)$row->rle_deleted ] : [] );
+          ] + ( $mode === self::$MODE_CHANGES ? [ 'deleted' => 
(bool)$row->rle_deleted ] : [] );
        }
 
        /**
diff --git a/src/Api/ApiQueryReadingLists.php b/src/Api/ApiQueryReadingLists.php
index c924489..cd024c8 100644
--- a/src/Api/ApiQueryReadingLists.php
+++ b/src/Api/ApiQueryReadingLists.php
@@ -13,28 +13,7 @@
 class ApiQueryReadingLists extends ApiQueryBase {
 
        use ApiTrait;
-
-       /**
-        * Return all lists.
-        * Intended for initial copy of data to a new device, or for devices 
which have information
-        * that's too outdated for normal sync. Might also be useful for 
devices with limited storage
-        * capacity, such as web clients.
-        */
-       const MODE_ALL = 'all';
-
-       /**
-        * Return lists which have been changed (or deleted) recently.
-        * Intended for syncing updates to a device which has an older snapshot 
of the data.
-        * "Recently" is defined by the changedsince parameter.
-        */
-       const MODE_CHANGES = 'changes';
-
-       /**
-        * Return lists which include a given page.
-        * Intended for status indicators and such (e.g. showing a star on the 
current page if it's
-        * included in some list).
-        */
-       const MODE_PAGE = 'page';
+       use ApiQueryTrait;
 
        /** @var string API module prefix */
        private static $prefix = 'rl';
@@ -54,8 +33,10 @@
                        $changedSince = $this->getParameter( 'changedsince' );
                        $project = $this->getParameter( 'project' );
                        $title = $this->getParameter( 'title' );
+                       $sort = $this->getParameter( 'sort' );
+                       $dir = $this->getParameter( 'dir' );
                        $limit = $this->getParameter( 'limit' );
-                       $offset = $this->getParameter( 'continue' );
+                       $continue = $this->getParameter( 'continue' );
 
                        $path = [ 'query', $this->getModuleName() ];
                        $result = $this->getResult();
@@ -65,7 +46,7 @@
                        $mode = null;
                        $this->requireMaxOneParameter( 
$this->extractRequestParams(), 'title', 'changedsince' );
                        if ( $project !== null && $title !== null ) {
-                               $mode = self::MODE_PAGE;
+                               $mode = self::$MODE_PAGE;
                        } elseif ( $project !== null || $title !== null ) {
                                $errorMessage = $this->msg( 
'readinglists-apierror-project-title-param', static::$prefix );
                                $this->dieWithError( $errorMessage, 
'missingparam' );
@@ -76,25 +57,37 @@
                                                wfTimestamp( TS_ISO_8601, 
$expiry ) );
                                        $this->dieWithError( $errorMessage );
                                }
-                               $mode = self::MODE_CHANGES;
+                               $mode = self::$MODE_CHANGES;
                        } else {
-                               $mode = self::MODE_ALL;
+                               $mode = self::$MODE_ALL;
                        }
 
-                       if ( $mode === self::MODE_PAGE ) {
-                               $res = $repository->getListsByPage( $project, 
$title, $limit + 1, $offset );
-                       } elseif ( $mode === self::MODE_CHANGES ) {
-                               $res = $repository->getListsByDateUpdated( 
$changedSince, $limit + 1, $offset );
-                       } else {
-                               $res = $repository->getAllLists( $limit + 1, 
$offset );
+                       if ( $sort === null ) {
+                               $sort = ( $mode === self::$MODE_CHANGES ) ? 
'updated' : 'name';
                        }
-                       $resultOffset = 0;
-                       foreach ( $res as $row ) {
-                               $isLastRow = ( $resultOffset === 
$res->numRows() - 1 );
+                       $sort = self::$sortParamMap[$sort];
+                       $dir = self::$sortParamMap[$dir];
+                       $continue = $this->decodeContinuationParameter( 
$continue, $mode, $sort );
+
+                       if ( $mode === self::$MODE_PAGE ) {
+                               $res = $repository->getListsByPage( $project, 
$title, $limit + 1, $continue );
+                       } elseif ( $mode === self::$MODE_CHANGES ) {
+                               $res = $repository->getListsByDateUpdated( 
$changedSince, $sort, $dir, $limit + 1, $continue );
+                       } else {
+                               $res = $repository->getAllLists( $sort, $dir, 
$limit + 1, $continue );
+                       }
+                       foreach ( $res as $i => $row ) {
                                $item = $this->getResultItem( $row, $mode );
+                               if ( $i > $limit ) {
+                                       // $i is 1-based so this means we 
reached the extra row.
+                                       $this->setContinueEnumParameter( 
'continue',
+                                               
$this->encodeContinuationParameter( $item, $mode, $sort ) );
+                                       break;
+                               }
                                $fits = $result->addValue( $path, null, $item );
-                               if ( !$fits || ++$resultOffset >= $limit && 
!$isLastRow ) {
-                                       $this->setContinueEnumParameter( 
'continue', $offset + $resultOffset );
+                               if ( !$fits ) {
+                                       $this->setContinueEnumParameter( 
'continue',
+                                               
$this->encodeContinuationParameter( $item, $mode, $sort ) );
                                        break;
                                }
                        }
@@ -120,19 +113,7 @@
                                self::PARAM_HELP_MSG => $this->msg( 
'apihelp-query+readinglists-param-changedsince',
                                        wfTimestamp( TS_ISO_8601, 
Utils::getDeletedExpiry() ) ),
                        ],
-                       'limit' => [
-                               self::PARAM_DFLT => 10,
-                               self::PARAM_TYPE => 'limit',
-                               self::PARAM_MIN => 1,
-                               self::PARAM_MAX => self::LIMIT_BIG1,
-                               self::PARAM_MAX2 => self::LIMIT_BIG2,
-                       ],
-                       'continue' => [
-                               self::PARAM_TYPE => 'integer',
-                               self::PARAM_DFLT => 0,
-                               self::PARAM_HELP_MSG => 
'api-help-param-continue',
-                       ],
-               ];
+               ] + $this->getAllowedSortParams();
        }
 
        /**
@@ -188,7 +169,7 @@
                        'created' => wfTimestamp( TS_ISO_8601, 
$row->rl_date_created ),
                        'updated' => wfTimestamp( TS_ISO_8601, 
$row->rl_date_updated ),
                ];
-               if ( $mode === self::MODE_CHANGES ) {
+               if ( $mode === self::$MODE_CHANGES ) {
                        $item['deleted'] = (bool)$row->rl_deleted;
                }
                return $item;
diff --git a/src/Api/ApiQueryTrait.php b/src/Api/ApiQueryTrait.php
new file mode 100644
index 0000000..f090fa0
--- /dev/null
+++ b/src/Api/ApiQueryTrait.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace MediaWiki\Extensions\ReadingLists\Api;
+
+use ApiUsageException;
+use MediaWiki\Extensions\ReadingLists\ReadingListRepository;
+
+/**
+ * Shared sorting / paging for the query APIs.
+ */
+trait ApiQueryTrait {
+
+       // Mode constants, to support different sorting / paging / deleted item 
behavior for different
+       // parameter combinations. For no particular reason, PHP does not allow 
constants in traits,
+       // so we'll use statics instead.
+
+       /**
+        * Return all lists, or all entries of the specified list(s).
+        * Intended for initial copy of data to a new device, or for devices 
which have information
+        * that's too outdated for normal sync. Might also be useful for 
devices with limited storage
+        * capacity, such as web clients.
+        */
+       private static $MODE_ALL = 'all';
+
+       /**
+        * Return lists/entries which have been changed (or deleted) recently.
+        * Intended for syncing updates to a device which has an older snapshot 
of the data.
+        * "Recently" is defined by the changedsince parameter.
+        */
+       private static $MODE_CHANGES = 'changes';
+
+       /**
+        * Return lists which include a given page.
+        * Intended for status indicators and such (e.g. showing a star on the 
current page if it's
+        * included in some list).
+        */
+       private static $MODE_PAGE = 'page';
+
+       /** @var string[] Map of sort keywords used by the API to sort keywords 
used by the repo. */
+       private static $sortParamMap = [
+               'name' => ReadingListRepository::SORT_BY_NAME,
+               'updated' => ReadingListRepository::SORT_BY_UPDATED,
+               'ascending' => ReadingListRepository::SORT_DIR_ASC,
+               'descending' => ReadingListRepository::SORT_DIR_DESC,
+       ];
+
+       /**
+        * Extract continuation data from item position and serialize it into a 
string.
+        * @param array $item Result item to continue from.
+        * @param string $mode One of the MODE_* constants.
+        * @param string $sort One of the SORT_BY_* constants.
+        * @return string
+        */
+       private function encodeContinuationParameter( array $item, $mode, $sort 
) {
+               if ( $mode === self::$MODE_PAGE ) {
+                       return $item['id'];
+               } elseif ( $sort === ReadingListRepository::SORT_BY_NAME ) {
+                       if ( self::$prefix === 'rl' ) {
+                               $name = $item['name'];
+                       } else {
+                               $name = $item['title'];
+                       }
+                       return $name . '|' . $item['id'];
+               } else {
+                       return $item['updated'] . '|' . $item['id'];
+               }
+       }
+
+       /**
+        * Recover continuation data after it has been roundtripped to the 
client.
+        * @param string|null $continue Continuation parameter returned by the 
client.
+        * @param string $mode One of the MODE_* constants.
+        * @param string $sort One of the SORT_BY_* constants.
+        * @return null|int|string[]
+        *   - null if there was no continuation parameter;
+        *   - [ rl(e)_name, rl(e)_id ] for MODE_ALL/MODE_CHANGES when sorting 
by name;
+        *   - [ rl(e)_date_updated, rl(e)_id ] for MODE_ALL/MODE_CHANGES when 
sorting by updated time;
+        *   - rle_id for MODE_PAGE.
+        * @throws ApiUsageException
+        */
+       private function decodeContinuationParameter( $continue, $mode, $sort ) 
{
+               if ( $continue === null ) {
+                       return null;
+               }
+
+               if ( $mode === self::$MODE_PAGE ) {
+                       $this->dieContinueUsageIf( $continue !== 
(string)(int)$continue );
+                       return (int)$continue;
+               } else {
+                       // Continue token format is '<name|timestamp>|<id>'; 
name can contain '|'.
+                       $separatorPosition = strrpos( $continue, '|' );
+                       $this->dieContinueUsageIf( $separatorPosition === false 
);
+                       $continue = [
+                               substr( $continue, 0, $separatorPosition ),
+                               substr( $continue, $separatorPosition + 1 ),
+                       ];
+                       $this->dieContinueUsageIf( $continue[1] !== 
(string)(int)$continue[1] );
+                       $continue[1] = (int)$continue[1];
+                       if ( $sort === ReadingListRepository::SORT_BY_UPDATED ) 
{
+                               $this->dieContinueUsageIf( wfTimestamp( TS_MW, 
$continue[0] ) === false );
+                       }
+                       return $continue;
+               }
+       }
+
+       /**
+        * Get common sorting/paging related params for getAllowedParams().
+        * @return array
+        */
+       private function getAllowedSortParams() {
+               return [
+                       'sort' => [
+                               self::PARAM_TYPE => [ 'name', 'updated' ],
+                               self::PARAM_HELP_MSG_PER_VALUE => [],
+                       ],
+                       'dir' => [
+                               self::PARAM_DFLT => 'ascending',
+                               self::PARAM_TYPE => [ 'ascending', 'descending' 
],
+                       ],
+                       'limit' => [
+                               self::PARAM_DFLT => 10,
+                               self::PARAM_TYPE => 'limit',
+                               self::PARAM_MIN => 1,
+                               self::PARAM_MAX => self::LIMIT_BIG1,
+                               self::PARAM_MAX2 => self::LIMIT_BIG2,
+                       ],
+                       'continue' => [
+                               self::PARAM_TYPE => 'string',
+                               self::PARAM_DFLT => null,
+                               self::PARAM_HELP_MSG => 
'api-help-param-continue',
+                       ],
+               ];
+       }
+
+}
diff --git a/src/HookHandler.php b/src/HookHandler.php
index f8333b8..c73d078 100644
--- a/src/HookHandler.php
+++ b/src/HookHandler.php
@@ -47,6 +47,8 @@
                        $updater->dropExtensionTable( 'reading_list_sortkey', 
"$patchDir/01-drop-sortkeys.sql" );
                        $updater->addExtensionTable( 'reading_list_project',
                                "$patchDir/02-add-reading_list_project.sql" );
+                       $updater->addExtensionIndex( 'reading_list', 
'rl_user_deleted_name_id',
+                               "$patchDir/03-add-sort-indexes.sql" );
                }
                return true;
        }
diff --git a/src/ReadingListRepository.php b/src/ReadingListRepository.php
index ed4351e..dda6e72 100644
--- a/src/ReadingListRepository.php
+++ b/src/ReadingListRepository.php
@@ -33,6 +33,15 @@
  */
 class ReadingListRepository implements IDBAccessObject, LoggerAwareInterface {
 
+       /** Sort lists / entries alphabetically by name / title. */
+       const SORT_BY_NAME = 'name';
+       /** Sort lists / entries chronologically by last updated timestamp. */
+       const SORT_BY_UPDATED = 'updated';
+       /** Sort ascendingly (first letter / oldest date first). */
+       const SORT_DIR_ASC = 'asc';
+       /** Sort descendingly (last letter / newest date first). */
+       const SORT_DIR_DESC = 'desc';
+
        /** @var array Database field lengths in bytes (only for the string 
types). */
        public static $fieldLength = [
                'rl_name' => 255,
@@ -234,27 +243,28 @@
 
        /**
         * Get all lists of the user.
+        * @param string $sortBy One of the SORT_BY_* constants.
+        * @param string $sortDir One of the SORT_DIR_* constants.
         * @param int $limit
-        * @param int $offset
-        * @return IResultWrapper<ReadingListRow>
+        * @param array|null $from DB position to continue from (or null to 
start at the beginning/end).
+        *   When sorting by name, this should be the name and id of a list; 
when sorting by update time,
+        *   the updated timestamp (in some form accepted by MWTimestamp) and 
the id.
+        * @return IResultWrapper <ReadingListRow>
         * @throws ReadingListRepositoryException
         */
-       public function getAllLists( $limit = 1000, $offset = 0 ) {
+       public function getAllLists( $sortBy, $sortDir, $limit = 1000, array 
$from = null ) {
                $this->assertUser();
+               list( $conditions, $options ) = $this->processSort( 'rl', 
$sortBy, $sortDir, $limit, $from );
 
                $res = $this->dbr->select(
                        'reading_list',
                        $this->getListFields(),
-                       [
+                       array_merge( [
                                'rl_user_id' => $this->userId,
                                'rl_deleted' => 0,
-                       ],
+                       ], $conditions ),
                        __METHOD__,
-                       [
-                               'LIMIT' => $limit,
-                               'OFFSET' => $offset,
-                               'ORDER BY' => 'rl_id',
-                       ]
+                       $options
                );
 
                if (
@@ -431,16 +441,23 @@
        /**
         * Get the entries of one or more lists.
         * @param array $ids List ids
+        * @param string $sortBy One of the SORT_BY_* constants.
+        * @param string $sortDir One of the SORT_DIR_* constants.
         * @param int $limit
-        * @param int $offset
+        * @param array|null $from DB position to continue from (or null to 
start at the beginning/end).
+        *   When sorting by name, this should be the name and id of a list; 
when sorting by update time,
+        *   the updated timestamp (in some form accepted by MWTimestamp) and 
the id.
         * @return IResultWrapper<ReadingListEntryRow>
         * @throws ReadingListRepositoryException
         */
-       public function getListEntries( array $ids, $limit = 1000, $offset = 0 
) {
+       public function getListEntries(
+               array $ids, $sortBy, $sortDir, $limit = 1000, array $from = null
+       ) {
                $this->assertUser();
                if ( !$ids ) {
                        throw new ReadingListRepositoryException( 
'readinglists-db-error-empty-list-ids' );
                }
+               list( $conditions, $options ) = $this->processSort( 'rle', 
$sortBy, $sortDir, $limit, $from );
 
                // sanity check for nice error messages
                $res = $this->dbr->select(
@@ -469,18 +486,14 @@
                $res = $this->dbr->select(
                        [ 'reading_list_entry', 'reading_list_project' ],
                        $this->getListEntryFields(),
-                       [
+                       array_merge( [
                                'rle_rlp_id = rlp_id',
                                'rle_rl_id' => $ids,
                                'rle_user_id' => $this->userId,
                                'rle_deleted' => 0,
-                       ],
+                       ], $conditions ),
                        __METHOD__,
-                       [
-                               'LIMIT' => $limit,
-                               'OFFSET' => $offset,
-                               'ORDER BY' => [ 'rle_rl_id', 'rle_id' ],
-                       ]
+                       $options
                );
 
                return $res;
@@ -542,26 +555,29 @@
         * Unlike other methods this returns deleted lists as well. Only 
changes to list metadata
         * (including deletion) are considered, not changes to list entries.
         * @param string $date The cutoff date in TS_MW format
+        * @param string $sortBy One of the SORT_BY_* constants.
+        * @param string $sortDir One of the SORT_DIR_* constants.
         * @param int $limit
-        * @param int $offset
+        * @param array|null $from DB position to continue from (or null to 
start at the beginning/end).
+        *   When sorting by name, this should be the name and id of a list; 
when sorting by update time,
+        *   the updated timestamp (in some form accepted by MWTimestamp) and 
the id.
         * @throws ReadingListRepositoryException
         * @return IResultWrapper<ReadingListRow>
         */
-       public function getListsByDateUpdated( $date, $limit = 1000, $offset = 
0 ) {
+       public function getListsByDateUpdated( $date, $sortBy = 
self::SORT_BY_UPDATED,
+               $sortDir = self::SORT_DIR_ASC, $limit = 1000, array $from = null
+       ) {
                $this->assertUser();
+               list( $conditions, $options ) = $this->processSort( 'rl', 
$sortBy, $sortDir, $limit, $from );
                $res = $this->dbr->select(
                        'reading_list',
                        $this->getListFields(),
-                       [
+                       array_merge( [
                                'rl_user_id' => $this->userId,
                                'rl_date_updated > ' . $this->dbr->addQuotes( 
$this->dbr->timestamp( $date ) ),
-                       ],
+                       ], $conditions ),
                        __METHOD__,
-                       [
-                               'LIMIT' => $limit,
-                               'OFFSET' => $offset,
-                               'ORDER BY' => 'rl_id',
-                       ]
+                       $options
                );
 
                if (
@@ -579,29 +595,32 @@
         * Unlike other methods this returns deleted entries as well (but not 
entries inside deleted
         * lists).
         * @param string $date The cutoff date in TS_MW format
+        * @param string $sortDir One of the SORT_DIR_* constants.
         * @param int $limit
-        * @param int $offset
+        * @param array|null $from DB position to continue from (or null to 
start at the beginning/end).
+        *   Should contain the updated timestamp (in some form accepted by 
MWTimestamp) and the id.
         * @throws ReadingListRepositoryException
         * @return IResultWrapper<ReadingListEntryRow>
         */
-       public function getListEntriesByDateUpdated( $date, $limit = 1000, 
$offset = 0 ) {
+       public function getListEntriesByDateUpdated(
+               $date, $sortDir = self::SORT_DIR_ASC, $limit = 1000, array 
$from = null
+       ) {
                $this->assertUser();
+               // Always sort by last updated; there is no supporting index 
for sorting by name.
+               list( $conditions, $options ) = $this->processSort( 'rle', 
self::SORT_BY_UPDATED,
+                       $sortDir, $limit, $from );
                $res = $this->dbr->select(
                        [ 'reading_list', 'reading_list_entry', 
'reading_list_project' ],
                        $this->getListEntryFields(),
-                       [
+                       array_merge( [
                                'rl_id = rle_rl_id',
                                'rle_rlp_id = rlp_id',
-                               'rl_user_id' => $this->userId,
+                               'rle_user_id' => $this->userId,
                                'rl_deleted' => 0,
                                'rle_date_updated > ' . $this->dbr->addQuotes( 
$this->dbr->timestamp( $date ) ),
-                       ],
+                       ], $conditions ),
                        __METHOD__,
-                       [
-                               'LIMIT' => $limit,
-                               'OFFSET' => $offset,
-                               'ORDER BY' => [ 'rle_rl_id', 'rle_id' ],
-                       ]
+                       $options
                );
                return $res;
        }
@@ -671,34 +690,39 @@
         * @param string $project Project identifier (typically a domain name)
         * @param string $title Page title (in localized prefixed DBkey format)
         * @param int $limit
-        * @param int $offset
+        * @param int|null $from List ID to continue from (or null to start at 
the beginning/end).
+        *
         * @throws ReadingListRepositoryException
         * @return IResultWrapper<ReadingListRow>
         */
-       public function getListsByPage( $project, $title, $limit = 1000, 
$offset = 0 ) {
+       public function getListsByPage( $project, $title, $limit = 1000, $from 
= null ) {
                $this->assertUser();
                $projectId = $this->getProjectId( $project );
                if ( !$projectId ) {
                        return new FakeResultWrapper( [] );
                }
 
+               $conditions = [
+                       'rl_id = rle_rl_id',
+                       'rle_user_id' => $this->userId,
+                       'rle_rlp_id' => $projectId,
+                       'rle_title' => $title,
+                       'rl_deleted' => 0,
+                       'rle_deleted' => 0,
+               ];
+               if ( $from !== null ) {
+                       $conditions[] = 'rle_rl_id >= ' . (int)$from;
+               }
                $res = $this->dbr->select(
                        [ 'reading_list', 'reading_list_entry' ],
                        $this->getListFields(),
-                       [
-                               'rl_id = rle_rl_id',
-                               'rl_user_id' => $this->userId,
-                               'rle_rlp_id' => $projectId,
-                               'rle_title' => $title,
-                               'rl_deleted' => 0,
-                               'rle_deleted' => 0,
-                       ],
+                       $conditions,
                        __METHOD__,
                        [
-                               'LIMIT' => $limit,
-                               'OFFSET' => $offset,
                                'GROUP BY' => $this->getListFields(),
-                               'ORDER BY' => 'rl_id',
+                               'ORDER BY' => 'rle_rl_id ASC',
+                               'LIMIT' => (int)$limit,
+
                        ]
                );
 
@@ -778,6 +802,63 @@
                                [ $field, self::$fieldLength[$field] ] );
                }
        }
+
+       /**
+        * Validate sort paramters.
+        * @param string $tablePrefix 'rl' or 'rle', depending on whether we 
are sorting lists or entries.
+        * @param string $sortBy
+        * @param string $sortDir
+        * @param int $limit
+        * @param array|null $from
+        * @return array [ conditions, options ] Merge these into the 
corresponding IDatabase::select
+        *   parameters.
+        */
+       private function processSort( $tablePrefix, $sortBy, $sortDir, $limit, 
$from ) {
+               if ( !in_array( $sortBy, [ self::SORT_BY_NAME, 
self::SORT_BY_UPDATED ], true ) ) {
+                       throw new LogicException( 'Invalid $sortBy parameter: ' 
. $sortBy );
+               }
+               if ( !in_array( $sortDir, [ self::SORT_DIR_ASC, 
self::SORT_DIR_DESC ], true ) ) {
+                       throw new LogicException( 'Invalid $sortDir parameter: 
' . $sortDir );
+               }
+               if ( is_array( $from ) ) {
+                       if ( count( $from ) !== 2 || !is_string( $from[0] ) || 
!is_numeric( $from[1] ) ) {
+                               throw new LogicException( 'Invalid $from 
parameter' );
+                       }
+               } elseif ( $from !== null ) {
+                       throw new LogicException( 'Invalid $from parameter 
type: ' . gettype( $from ) );
+               }
+
+               if ( $tablePrefix === 'rl' ) {
+                       $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 
'rl_name' : 'rl_date_updated';
+               } else {
+                       $mainField = ( $sortBy === self::SORT_BY_NAME ) ? 
'rle_title' : 'rle_date_updated';
+               }
+               $idField = "${tablePrefix}_id";
+               $conditions = [];
+               $options = [
+                       'ORDER BY' => [ "$mainField $sortDir", "$idField 
$sortDir" ],
+                       'LIMIT' => (int)$limit,
+               ];
+
+               if ( $from !== null ) {
+                       $op = ( $sortDir === self::SORT_DIR_ASC ) ? '>' : '<';
+                       $safeFromMain = ( $sortBy === self::SORT_BY_NAME )
+                               ? $this->dbr->addQuotes( $from[0] )
+                               : $this->dbr->addQuotes( $this->dbr->timestamp( 
$from[0] ) );
+                       $safeFromId = (int)$from[1];
+                       $conditions[] = $this->dbr->makeList( [
+                               "$mainField $op $safeFromMain",
+                               $this->dbr->makeList( [
+                                       "$mainField = $safeFromMain",
+                                       "$idField $op= $safeFromId",
+                               ], IDatabase::LIST_AND ),
+                       ], IDatabase::LIST_OR );
+               }
+
+               // note: $conditions will be array_merge-d so it should not 
contain non-numeric keys
+               return [ $conditions, $options ];
+       }
+
        /**
         * Get list data, and optionally lock the list.
         * List must exist, belong to the current user and not be deleted.
diff --git a/tests/src/ReadingListRepositoryTest.php 
b/tests/src/ReadingListRepositoryTest.php
index 94a968d..fcd1c5c 100644
--- a/tests/src/ReadingListRepositoryTest.php
+++ b/tests/src/ReadingListRepositoryTest.php
@@ -51,11 +51,13 @@
                        [ 'teardownForUser' ],
                        [ 'isSetupForUser' ],
                        [ 'addList', 'foo' ],
-                       [ 'getAllLists' ],
+                       [ 'getAllLists', ReadingListRepository::SORT_BY_NAME,
+                               ReadingListRepository::SORT_DIR_ASC ],
                        [ 'updateList', 1, 'foo' ],
                        [ 'deleteList', 1 ],
                        [ 'addListEntry', 1, 'foo', 'bar' ],
-                       [ 'getListEntries', [ 1 ] ],
+                       [ 'getListEntries', [ 1 ], 
ReadingListRepository::SORT_BY_NAME,
+                               ReadingListRepository::SORT_DIR_ASC ],
                        [ 'deleteListEntry', 1 ],
                        [ 'getListsByDateUpdated', wfTimestampNow() ],
                        [ 'getListsByPage', 'foo', 'bar' ],
@@ -87,7 +89,8 @@
                return [
                        [ 'teardownForUser' ],
                        [ 'addList', 'foo' ],
-                       [ 'getAllLists' ],
+                       [ 'getAllLists', ReadingListRepository::SORT_BY_NAME,
+                               ReadingListRepository::SORT_DIR_ASC ],
                        [ 'getListsByDateUpdated', wfTimestampNow() ],
                        [ 'getListsByPage', 'foo', 'bar' ],
                ];
@@ -184,7 +187,12 @@
                } );
        }
 
-       public function testGetAllLists() {
+       /**
+        * @dataProvider provideGetAllLists
+        * @param array $args
+        * @param array $expected
+        */
+       public function testGetAllLists( array $args, array $expected ) {
                $this->addDataForAnotherUser();
                $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
                $repository->setupForUser();
@@ -198,8 +206,8 @@
                        [
                                'rl_name' => 'foo',
                                'rl_description' => 'this is the second foo',
-                               'rl_date_created' => wfTimestampNow(),
-                               'rl_date_updated' => wfTimestampNow(),
+                               'rl_date_created' => '20170101000000',
+                               'rl_date_updated' => '20170101000000',
                                'rl_deleted' => '0',
                        ],
                        [
@@ -210,14 +218,16 @@
                        ],
                        [
                                'rl_name' => 'baz',
-                               'rl_date_created' => wfTimestampNow(),
-                               'rl_date_updated' => wfTimestampNow(),
+                               'rl_date_created' => '20170101000000',
+                               'rl_date_updated' => '20170101000000',
                                'rl_deleted' => '1',
                        ],
                ] );
                $compareResultItems = function ( array $expected, array $actual 
) {
-                       $this->assertTimestampEquals( 
$expected['rl_date_created'], $actual['rl_date_created'] );
-                       $this->assertTimestampEquals( 
$expected['rl_date_updated'], $actual['rl_date_updated'] );
+                       $this->assertTimestampEquals( 
$expected['rl_date_created'], $actual['rl_date_created'],
+                               "expected: {$expected['rl_name']}; actual: 
{$actual['rl_name']}" );
+                       $this->assertTimestampEquals( 
$expected['rl_date_updated'], $actual['rl_date_updated'],
+                               "expected: {$expected['rl_name']}; actual: 
{$actual['rl_name']}" );
                        unset( $expected['rl_date_created'], 
$expected['rl_date_updated'] );
                        unset( $actual['rl_id'], $actual['rl_date_created'], 
$actual['rl_date_updated'] );
                        $this->assertArrayEquals( $expected, $actual, false, 
true );
@@ -228,9 +238,13 @@
                        array_map( $compareResultItems, $expected, $data );
                };
 
-               $res = $repository->getAllLists();
-               $expectedData = [
-                       [
+               $res = call_user_func_array( [ $repository, 'getAllLists' ], 
$args );
+               $compare( $expected, $res );
+       }
+
+       public function provideGetAllLists() {
+               $entries = [
+                       'default' => [
                                'rl_name' => 'default',
                                'rl_description' => '',
                                'rl_color' => '',
@@ -241,7 +255,7 @@
                                'rl_date_updated' => wfTimestampNow(),
                                'rl_deleted' => '0',
                        ],
-                       [
+                       'foo' => [
                                'rl_name' => 'foo',
                                'rl_description' => '',
                                'rl_color' => '',
@@ -252,18 +266,18 @@
                                'rl_date_updated' => '20120101000000',
                                'rl_deleted' => '0',
                        ],
-                       [
+                       'foo_2' => [
                                'rl_name' => 'foo',
                                'rl_description' => 'this is the second foo',
                                'rl_color' => '',
                                'rl_image' => '',
                                'rl_icon' => '',
                                'rl_is_default' => '0',
-                               'rl_date_created' => wfTimestampNow(),
-                               'rl_date_updated' => wfTimestampNow(),
+                               'rl_date_created' => '20170101000000',
+                               'rl_date_updated' => '20170101000000',
                                'rl_deleted' => '0',
                        ],
-                       [
+                       'bar' => [
                                'rl_name' => 'bar',
                                'rl_description' => '',
                                'rl_color' => '',
@@ -275,16 +289,42 @@
                                'rl_deleted' => '0',
                        ],
                ];
-               $compare( $expectedData, $res );
+               // 1 list from addDataForAnotherUser, 1 from setupForUser, 
second list in addLists call
+               $foo2Id = 4;
 
-               $res = $repository->getAllLists( 1 );
-               $compare( array_slice( $expectedData, 0, 1 ), $res );
-
-               $res = $repository->getAllLists( 1, 1 );
-               $compare( array_slice( $expectedData, 1, 1 ), $res );
-
-               $res = $repository->getAllLists( 1, 10 );
-               $this->assertSame( 0, iterator_count( $res ) );
+               return [
+                       'name, basic' => [
+                               [ ReadingListRepository::SORT_BY_NAME, 
ReadingListRepository::SORT_DIR_ASC ],
+                               [ $entries['bar'], $entries['default'], 
$entries['foo'], $entries['foo_2'] ],
+                       ],
+                       'name, reverse' => [
+                               [ ReadingListRepository::SORT_BY_NAME, 
ReadingListRepository::SORT_DIR_DESC ],
+                               [ $entries['foo_2'], $entries['foo'], 
$entries['default'], $entries['bar'] ],
+                       ],
+                       'name, limit' => [
+                               [ ReadingListRepository::SORT_BY_NAME, 
ReadingListRepository::SORT_DIR_ASC, 1 ],
+                               [ $entries['bar'] ],
+                       ],
+                       'name, limit + offset' => [
+                               [ ReadingListRepository::SORT_BY_NAME, 
ReadingListRepository::SORT_DIR_ASC,
+                                       1, [ 'default', 1 ] ],
+                               [ $entries['default'] ],
+                       ],
+                       'name, limit + other offset' => [
+                               [ ReadingListRepository::SORT_BY_NAME, 
ReadingListRepository::SORT_DIR_ASC,
+                                       1, [ 'foo', $foo2Id ] ],
+                               [ $entries['foo_2'] ],
+                       ],
+                       'updated, basic' => [
+                               [ ReadingListRepository::SORT_BY_UPDATED, 
ReadingListRepository::SORT_DIR_ASC ],
+                               [ $entries['bar'], $entries['foo'], 
$entries['foo_2'], $entries['default'] ],
+                       ],
+                       'updated, limit + offset' => [
+                               [ ReadingListRepository::SORT_BY_UPDATED, 
ReadingListRepository::SORT_DIR_ASC,
+                                       1, [ '20170101000000', $foo2Id ] ],
+                               [ $entries['foo_2'] ],
+                       ],
+               ];
        }
 
        public function testUpdateList() {
@@ -493,11 +533,15 @@
                );
        }
 
-       public function testGetListEntries() {
+       /**
+        * @dataProvider provideGetListEntries
+        * @param array $args
+        * @param array $expected
+        */
+       public function testGetListEntries( array $args, array $expected ) {
                $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
                $repository->setupForUser();
-               $defaultId = $this->db->selectField( 'reading_list', 'rl_id',
-                       [ 'rl_user_id' => 1, 'rl_is_default' => 1 ] );
+               $defaultId = 1;
                $this->addListEntries( $defaultId, 1, [
                        [
                                'rle_user_id' => 1,
@@ -508,52 +552,49 @@
                                'rle_deleted' => 0,
                        ],
                ] );
-               list( $listId, $deletedListId ) = $this->addLists( 1, [
+               $this->addLists( 1, [
                        [
                                'rl_is_default' => 0,
                                'rl_name' => 'test',
                                'entries' => [
                                        [
                                                'rlp_project' => 'foo',
-                                               'rle_title' => 'bar',
-                                               'rle_date_created' => 
wfTimestampNow(),
-                                               'rle_date_updated' => 
wfTimestampNow(),
+                                               'rle_title' => 'Bar',
+                                               'rle_date_created' => 
'20100101000000',
+                                               'rle_date_updated' => 
'20150101000000',
                                                'rle_deleted' => 0,
                                        ],
                                        [
                                                'rlp_project' => 'foo2',
-                                               'rle_title' => 'bar2',
+                                               'rle_title' => 'Bar2',
                                                'rle_date_created' => 
'20100101000000',
                                                'rle_date_updated' => 
'20120101000000',
                                                'rle_deleted' => 0,
                                        ],
                                        [
                                                'rlp_project' => 'foo3',
-                                               'rle_title' => 'bar3',
-                                               'rle_date_created' => 
wfTimestampNow(),
-                                               'rle_date_updated' => 
wfTimestampNow(),
+                                               'rle_title' => 'Bar2',
+                                               'rle_date_created' => 
'20100101000000',
+                                               'rle_date_updated' => 
'20170101000000',
                                                'rle_deleted' => 0,
                                        ],
                                        [
                                                'rlp_project' => 'foo4',
-                                               'rle_title' => 'bar4',
-                                               'rle_date_created' => 
wfTimestampNow(),
-                                               'rle_date_updated' => 
wfTimestampNow(),
+                                               'rle_title' => 'Bar4',
+                                               'rle_date_created' => 
'20100101000000',
+                                               'rle_date_updated' => 
'20160101000000',
                                                'rle_deleted' => 1,
                                        ],
                                ],
                        ],
-                       [
-                               'rl_is_default' => 0,
-                               'rl_name' => 'test-deleted',
-                               'rl_deleted' => 1,
-                       ],
                ] );
                $compareResultItems = function ( $expected, $actual, $n ) {
+                       $error = "Mismatch in item $n (expected project/title: 
{$expected['rlp_project']}"
+                               . "/{$expected['rle_title']}; actual: 
{$actual['rlp_project']}/{$actual['rle_title']})";
                        $this->assertTimestampEquals( 
$expected['rle_date_created'], $actual['rle_date_created'],
-                               "Mismatch in item $n" );
+                               $error );
                        $this->assertTimestampEquals( 
$expected['rle_date_updated'], $actual['rle_date_updated'],
-                               "Mismatch in item $n" );
+                               $error );
                        unset( $expected['rle_date_created'], 
$expected['rle_date_updated'] );
                        unset( $actual['rle_id'], $actual['rle_rlp_id'],
                                $actual['rle_date_created'], 
$actual['rle_date_updated'] );
@@ -565,9 +606,15 @@
                        array_map( $compareResultItems, $expected, $data, 
range( 1, count( $expected ) ) );
                };
 
-               $res = $repository->getListEntries( [ $defaultId, $listId ] );
-               $expectedData = [
-                       [
+               $res = call_user_func_array( [ $repository, 'getListEntries' ], 
$args );
+               $compare( $expected, $res );
+       }
+
+       public function provideGetListEntries() {
+               $defaultId = 1;
+               $testId = 2;
+               $expected = [
+                       'default-foo' => [
                                'rle_rl_id' => $defaultId,
                                'rlp_project' => 'foo',
                                'rle_title' => 'Foo',
@@ -575,61 +622,105 @@
                                'rle_date_updated' => wfTimestampNow(),
                                'rle_deleted' => 0,
                        ],
-                       [
-                               'rle_rl_id' => $listId,
+                       'list-foo' => [
+                               'rle_rl_id' => $testId,
                                'rlp_project' => 'foo',
-                               'rle_title' => 'bar',
-                               'rle_date_created' => wfTimestampNow(),
-                               'rle_date_updated' => wfTimestampNow(),
+                               'rle_title' => 'Bar',
+                               'rle_date_created' => '20100101000000',
+                               'rle_date_updated' => '20150101000000',
                                'rle_deleted' => 0,
                        ],
-                       [
-                               'rle_rl_id' => $listId,
+                       'list-foo2' => [
+                               'rle_rl_id' => $testId,
                                'rlp_project' => 'foo2',
-                               'rle_title' => 'bar2',
+                               'rle_title' => 'Bar2',
                                'rle_date_created' => '20100101000000',
                                'rle_date_updated' => '20120101000000',
                                'rle_deleted' => 0,
                        ],
-                       [
-                               'rle_rl_id' => $listId,
+                       'list-foo3' => [
+                               'rle_rl_id' => $testId,
                                'rlp_project' => 'foo3',
-                               'rle_title' => 'bar3',
-                               'rle_date_created' => wfTimestampNow(),
-                               'rle_date_updated' => wfTimestampNow(),
+                               'rle_title' => 'Bar2',
+                               'rle_date_created' => '20100101000000',
+                               'rle_date_updated' => '20170101000000',
                                'rle_deleted' => 0,
                        ],
                ];
-               $compare( $expectedData, $res );
 
-               $res = $repository->getListEntries( [ $listId ] );
-               $compare( array_slice( $expectedData, 1 ), $res );
+               return [
+                       'name, basic' => [
+                               [ [ $defaultId, $testId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC ],
+                               [ $expected['list-foo'], 
$expected['list-foo2'], $expected['list-foo3'],
+                                       $expected['default-foo'] ],
+                       ],
+                       'name, desc' => [
+                               [ [ $defaultId, $testId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_DESC ],
+                               [ $expected['default-foo'], 
$expected['list-foo3'], $expected['list-foo2'],
+                                       $expected['list-foo'] ],
+                       ],
+                       'name, offset + limit' => [
+                               [ [ $defaultId, $testId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC, 2, 
[ 'Bar2', 3 ] ],
+                               [ $expected['list-foo2'], 
$expected['list-foo3'] ],
+                       ],
+                       'tiebreaker' => [
+                               [ [ $defaultId, $testId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC, 2, 
[ 'Bar2', 4 ] ],
+                               [ $expected['list-foo3'], 
$expected['default-foo'] ],
+                       ],
+                       'updated' => [
+                               [ [ $defaultId, $testId ], 
ReadingListRepository::SORT_BY_UPDATED,
+                                       ReadingListRepository::SORT_DIR_ASC ],
+                               [ $expected['list-foo2'], 
$expected['list-foo'], $expected['list-foo3'],
+                                       $expected['default-foo'] ],
+                       ],
+                       'filter by list id' => [
+                               [ [ $defaultId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC ],
+                               [ $expected['default-foo'] ],
+                       ],
+               ];
+       }
 
-               $res = $repository->getListEntries( [ $defaultId, $listId ], 2 
);
-               $compare( array_slice( $expectedData, 0, 2 ), $res );
-
-               $res = $repository->getListEntries( [ $defaultId, $listId ], 2, 
2 );
-               $compare( array_slice( $expectedData, 2, 2 ), $res );
+       // @codingStandardsIgnoreLine 
MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
+       public function testGetListEntries_error() {
+               $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
+               $repository->setupForUser();
+               $defaultId = 1;
+               list( $deletedListId ) = $this->addLists( 1, [
+                       [
+                               'rl_is_default' => 0,
+                               'rl_name' => 'test-deleted',
+                               'rl_deleted' => 1,
+                       ],
+               ] );
 
                $this->assertFailsWith( 'readinglists-db-error-empty-list-ids',
                        function () use ( $repository ) {
-                               $repository->getListEntries( [] );
+                               $repository->getListEntries( [], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC );
                        }
                );
                $this->assertFailsWith( 'readinglists-db-error-no-such-list',
                        function () use ( $repository ) {
-                               $repository->getListEntries( [ 123 ] );
+                               $repository->getListEntries( [ 123 ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC );
                        }
                );
                $this->assertFailsWith( 'readinglists-db-error-not-own-list',
                        function () use ( $defaultId ) {
                                $repository = new ReadingListRepository( 123, 
$this->db, $this->db, $this->lbFactory );
-                               $repository->getListEntries( [ $defaultId ] );
+                               $repository->getListEntries( [ $defaultId ], 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC );
                        }
                );
                $this->assertFailsWith( 'readinglists-db-error-list-deleted',
                        function () use ( $repository, $deletedListId ) {
-                               $repository->getListEntries( [ $deletedListId ] 
);
+                               $repository->getListEntries( [ $deletedListId 
], ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC );
                        }
                );
        }
@@ -716,61 +807,106 @@
                );
        }
 
-       public function testGetListsByDateUpdated() {
+       /**
+        * @dataProvider provideGetListsByDateUpdated
+        * @param array $args
+        * @param array $expected
+        */
+       public function testGetListsByDateUpdated( array $args, array $expected 
) {
                $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
                $this->addLists( 1, [
                        [
-                               'rl_name' => 'new',
+                               'rl_name' => 'foo',
+                               'rl_description' => 'list1',
                                'rl_date_updated' => '20150101000000',
                        ],
                        [
-                               'rl_name' => 'deleted',
+                               'rl_name' => 'foo',
+                               'rl_description' => 'list2',
+                               'rl_date_updated' => '20120101000000',
+                       ],
+                       [
+                               'rl_name' => 'foo2',
+                               'rl_description' => 'list3',
+                               'rl_date_updated' => '20150101000000',
+                       ],
+                       [
+                               'rl_name' => 'foo3',
+                               'rl_description' => 'list4',
+                               'rl_date_updated' => '20170101000000',
+                       ],
+                       [
+                               'rl_name' => 'foo',
+                               'rl_description' => 'too-old',
+                               'rl_date_updated' => '20080101000000',
+                       ],
+                       [
+                               'rl_name' => 'foo',
+                               'rl_description' => 'deleted',
                                'rl_deleted' => 1,
                                'rl_date_updated' => '20150102000000',
                        ],
-                       [
-                               'rl_name' => 'old',
-                               'rl_date_updated' => '20080101000000',
-                       ],
                ] );
 
-               $expected = [ 'new', 'deleted' ];
-               $res = $repository->getListsByDateUpdated( '20100101000000' );
-               $data = $this->resultWrapperToArray( $res, 'rl_name' );
+               $res = call_user_func_array( [ $repository, 
'getListsByDateUpdated' ], $args );
+               $data = $this->resultWrapperToArray( $res, 'rl_description' );
                $this->assertArrayEquals( $expected, $data );
-
-               $res = $repository->getListsByDateUpdated( '20100101000000', 1 
);
-               $data = $this->resultWrapperToArray( $res, 'rl_name' );
-               $this->assertCount( 1, $data );
-               $this->assertSubset( $data, $expected );
-
-               $res = $repository->getListsByDateUpdated( '20100101000000', 1, 
1 );
-               $data2 = $this->resultWrapperToArray( $res, 'rl_name' );
-               $this->assertCount( 1, $data2 );
-               $this->assertSubset( $data2, $expected );
-               $this->assertNotEquals( $data, $data2 );
        }
 
-       public function testGetListEntriesByDateUpdated() {
+       public function provideGetListsByDateUpdated() {
+               return [
+                       'basic' => [
+                               [ '20100101000000' ],
+                               [ 'list2', 'list1', 'list3', 'deleted', 'list4' 
],
+                       ],
+                       'desc' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_BY_UPDATED,
+                                       ReadingListRepository::SORT_DIR_DESC ],
+                               [ 'list4', 'deleted', 'list3', 'list1', 'list2' 
],
+                       ],
+                       'limit + offset' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_BY_UPDATED,
+                                       ReadingListRepository::SORT_DIR_ASC, 2, 
[ '20150101000000', 3 ] ],
+                               [ 'list3', 'deleted' ],
+                       ],
+                       'name' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_BY_NAME,
+                                       ReadingListRepository::SORT_DIR_ASC ],
+                               [ 'list1', 'list2', 'deleted', 'list3', 'list4' 
],
+                       ]
+               ];
+       }
+
+       /**
+        * @dataProvider provideGetListEntriesByDateUpdated
+        * @param array $args
+        * @param array $expected
+        */
+       public function testGetListEntriesByDateUpdated( array $args, array 
$expected ) {
                $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
                $this->addLists( 1, [
                        [
                                'rl_name' => 'one',
                                'entries' => [
                                        [
-                                               'rlp_project' => 'new',
-                                               'rle_title' => 'new',
+                                               'rlp_project' => 'foo',
+                                               'rle_title' => 'foo',
                                                'rle_date_updated' => 
'20150101000000',
                                        ],
                                        [
-                                               'rlp_project' => 'deleted',
-                                               'rle_title' => 'deleted',
+                                               'rlp_project' => 'foo-deleted',
+                                               'rle_title' => 'foo',
                                                'rle_deleted' => 1,
                                                'rle_date_updated' => 
'20150101000000',
                                        ],
                                        [
-                                               'rlp_project' => 'old',
-                                               'rle_title' => 'old',
+                                               'rlp_project' => 'foo2',
+                                               'rle_title' => 'foo',
+                                               'rle_date_updated' => 
'20170101000000',
+                                       ],
+                                       [
+                                               'rlp_project' => 'too-old',
+                                               'rle_title' => 'foo',
                                                'rle_date_updated' => 
'20080101000000',
                                        ],
                                ],
@@ -779,8 +915,8 @@
                                'rl_name' => 'two',
                                'entries' => [
                                        [
-                                               'rlp_project' => 'other',
-                                               'rle_title' => 'other',
+                                               'rlp_project' => 'bar',
+                                               'rle_title' => 'bar',
                                                'rle_date_updated' => 
'20150101000000',
                                        ],
                                ],
@@ -791,28 +927,40 @@
                                'entries' => [
                                        [
                                                'rlp_project' => 'parent 
deleted',
-                                               'rle_title' => 'parent deleted',
+                                               'rle_title' => 'bar',
                                                'rle_date_updated' => 
'20150101000000',
                                        ],
                                ],
                        ],
                ] );
-
-               $expected = [ 'new', 'deleted', 'other' ];
-               $res = $repository->getListEntriesByDateUpdated( 
'20100101000000' );
-               $data = $this->resultWrapperToArray( $res, 'rle_title' );
+               $res = call_user_func_array( [ $repository, 
'getListEntriesByDateUpdated' ], $args );
+               $data = $this->resultWrapperToArray( $res, 'rlp_project' );
                $this->assertArrayEquals( $expected, $data );
+       }
 
-               $res = $repository->getListEntriesByDateUpdated( 
'20100101000000', 1 );
-               $data = $this->resultWrapperToArray( $res, 'rle_title' );
-               $this->assertCount( 1, $data );
-               $this->assertSubset( $data, $expected );
-
-               $res = $repository->getListEntriesByDateUpdated( 
'20100101000000', 1, 1 );
-               $data2 = $this->resultWrapperToArray( $res, 'rle_title' );
-               $this->assertCount( 1, $data2 );
-               $this->assertSubset( $data2, $expected );
-               $this->assertNotEquals( $data, $data2 );
+       public function provideGetListEntriesByDateUpdated() {
+               return [
+                       'basic' => [
+                               [ '20100101000000' ],
+                               [ 'foo', 'foo-deleted', 'bar', 'foo2' ],
+                       ],
+                       'desc' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_DIR_DESC ],
+                               [ 'foo2', 'bar', 'foo-deleted', 'foo' ],
+                       ],
+                       'limit + offset' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_DIR_ASC, 2, [ '20150101000000', 2 ] ],
+                               [ 'foo-deleted', 'bar' ],
+                       ],
+                       'limit + offset 2' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_DIR_ASC, 2, [ '20150101000000', 5 ] ],
+                               [ 'bar', 'foo2' ],
+                       ],
+                       'limit + offset 3' => [
+                               [ '20100101000000', 
ReadingListRepository::SORT_DIR_ASC, 2, [ '20170101000000', 3 ] ],
+                               [ 'foo2' ],
+                       ],
+               ];
        }
 
        public function testPurgeOldDeleted() {
@@ -871,7 +1019,12 @@
                        'kept-parent-deleted-new', 
'deleted-new-parent-deleted-new' ], $keptEntries );
        }
 
-       public function testGetListsByPage() {
+       /**
+        * @dataProvider provideGetListsByPage
+        * @param array $args
+        * @param array $expected
+        */
+       public function testGetListsByPage( array $args, array $expected ) {
                $repository = new ReadingListRepository( 1, $this->db, 
$this->db, $this->lbFactory );
                $this->addLists( 1, [
                        [
@@ -936,21 +1089,30 @@
                        ],
                ] );
 
-               $expected = [ 'fourth', 'fifth' ];
-               $res = $repository->getListsByPage( '-', 'x' );
+               $res = call_user_func_array( [ $repository, 'getListsByPage' ], 
$args );
                $data = $this->resultWrapperToArray( $res, 'rl_name' );
                $this->assertArrayEquals( $expected, $data );
+       }
 
-               $res = $repository->getListsByPage( '-', 'x', 1 );
-               $data = $this->resultWrapperToArray( $res, 'rl_name' );
-               $this->assertCount( 1, $data );
-               $this->assertSubset( $data, $expected );
-
-               $res = $repository->getListsByPage( '-', 'x', 1, 1 );
-               $data2 = $this->resultWrapperToArray( $res, 'rl_name' );
-               $this->assertCount( 1, $data2 );
-               $this->assertSubset( $data2, $expected );
-               $this->assertNotEquals( $data, $data2 );
+       public function provideGetListsByPage() {
+               return [
+                       'basic' => [
+                               [ '-', 'x' ],
+                               [ 'fourth', 'fifth' ],
+                       ],
+                       'limit' => [
+                               [ '-', 'x', 1 ],
+                               [ 'fourth' ],
+                       ],
+                       'limit + offset' => [
+                               [ '-', 'x', 1, 4 ],
+                               [ 'fourth' ],
+                       ],
+                       'limit + offset 2' => [
+                               [ '-', 'x', 1, 5 ],
+                               [ 'fifth' ],
+                       ],
+               ];
        }
 
        // -------------------------------------------
@@ -981,7 +1143,7 @@
                }
                $delta = abs( wfTimestamp( TS_UNIX, $expectedTimestamp )
                        - wfTimestamp( TS_UNIX, $actualTimestamp ) );
-               $this->assertLessThanOrEqual( 3, $delta,
+               $this->assertLessThanOrEqual( 30, $delta,
                        "${msg}Difference between expected timestamp 
($expectedTimestamp) "
                        . "and actual timetamp ($actualTimestamp) is too large" 
);
        }

-- 
To view, visit https://gerrit.wikimedia.org/r/393925
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ia04a6c42ed7eca2a1c49d6ef109f0359db06ccd4
Gerrit-PatchSet: 7
Gerrit-Project: mediawiki/extensions/ReadingLists
Gerrit-Branch: master
Gerrit-Owner: GergÅ‘ Tisza <[email protected]>
Gerrit-Reviewer: GergÅ‘ Tisza <[email protected]>
Gerrit-Reviewer: Mholloway <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to