Gergő Tisza has uploaded a new change for review. (
https://gerrit.wikimedia.org/r/393925 )
Change subject: [WIP] Sort lists and entries by name and last updated timestamp
......................................................................
[WIP] Sort lists and entries by name and last updated timestamp
TODO:
* perf issues (possibly due to small data set?):
** getAllLists: filesort, uses no key (somtimes)
** getListsByDateUpdated: filesort, uses no key
** getListEntriesByDateUpdated: temporary + filesort, uses
rle_list_deleted_title_id instead of rle_list_deleted_title_id
** getListsByPage: temporary + filesort, uses
rle_list_deleted_title_id instead of rle_user_project_title
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.
Improve performance by aligning indexes with sort options
(and getting rid of the old offset-based paging):
* getAllLists, getListEntries, and purgeOldDeleted have a matching
index (for both sort methods, in the case of the first two);
* getListsByDateUpdated will fix rl_user_id and page on rl_name
or rl_date_updated;
* getListEntriesByDateUpdated;
* getListsByPage
May have gone a bit overboard with the indexes but in previous
code review there was a preference for avoiding filesorts even
on smallish resut sets and it takes this many to accomplish that.
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, 695 insertions(+), 287 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/ReadingLists
refs/changes/25/393925/1
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..b54744f 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. 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> / <kbd>newer</kbd> for ascending (A to Z, oldest to
newest), <kbd>descending</kbd> / <kbd>older</kbd> for descending.",
"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> / <kbd>newer</kbd> for ascending (A to Z, oldest to
newest), <kbd>descending</kbd> / <kbd>older</kbd> for descending.",
"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..c20bd47 100644
--- a/sql/readinglists.sql
+++ b/sql/readinglists.sql
@@ -29,11 +29,18 @@
-- 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() and
+-- getListsByDateUpdated() with SORT_BY_NAME (the latter with a union index
merge on rl_deleted).
+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() and getListsByDateUpdated() with SORT_BY_UPDATED (the latter
with a union index
+-- merge on rl_deleted).
+CREATE UNIQUE INDEX /*i*/rl_user_deleted_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 +63,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..4444539 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,28 @@
$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 ) {
+ $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 +138,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 +196,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..0827d49 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,36 @@
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 ) {
+ $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 +112,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 +168,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..a750ddf
--- /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,
+ // newer: sort from older to newer (ie. oldest first); older:
from newer to older.
+ // Not super intuitive naming but matches other modules.
+ 'newer' => ReadingListRepository::SORT_DIR_ASC,
+ 'older' => 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 ) {
+ return $item['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', 'newer', 'older' ],
+ ],
+ '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..b012cfe 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,21 @@
/**
* 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 +484,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 +553,31 @@
* 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,
+ // help the query optimizer a bit
+ 'rl_deleted' => [ 0, 1 ],
'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..1489b30 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,104 @@
'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 );
+ 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 +806,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 +914,8 @@
'rl_name' => 'two',
'entries' => [
[
- 'rlp_project' => 'other',
- 'rle_title' => 'other',
+ 'rlp_project' => 'bar',
+ 'rle_title' => 'bar',
'rle_date_updated' =>
'20150101000000',
],
],
@@ -791,28 +926,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 +1018,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 +1088,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 +1142,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: newchange
Gerrit-Change-Id: Ia04a6c42ed7eca2a1c49d6ef109f0359db06ccd4
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/ReadingLists
Gerrit-Branch: master
Gerrit-Owner: Gergő Tisza <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits