Gergő Tisza has uploaded a new change for review. (
https://gerrit.wikimedia.org/r/365987 )
Change subject: [WIP] DB layer
......................................................................
[WIP] DB layer
Bug: T168974
Change-Id: Ifb3c7538feb377e52582cf21131b5337830dde4f
---
M extension.json
M phpcs.xml
A sql/readinglists.sql
A src/HookHandler.php
A src/ReadingListRepository.php
A src/ReadingListRepositoryException.php
A src/Utils.php
A src/doc/ReadingListAndEntryRow.php
A src/doc/ReadingListEntryRow.php
A src/doc/ReadingListRow.php
10 files changed, 1,178 insertions(+), 2 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/ReadingLists
refs/changes/87/365987/1
diff --git a/extension.json b/extension.json
index b811071..c356c79 100644
--- a/extension.json
+++ b/extension.json
@@ -9,15 +9,34 @@
"license-name": "MIT",
"type": "other",
"AutoloadClasses": {
+ "MediaWiki\\Extensions\\ReadingLists\\HookHandler":
"src/HookHandler.php",
+ "MediaWiki\\Extensions\\ReadingLists\\Utils": "src/Utils.php",
+ "MediaWiki\\Extensions\\ReadingLists\\ReadingListRepository":
"src/ReadingListRepository.php",
+
"MediaWiki\\Extensions\\ReadingLists\\ReadingListRepositoryException":
"src/ReadingListRepositoryException.php"
},
"config": {
+ "ReadingListsCluster": {
+ "value": false,
+ "description": "Database cluster to use for storing the
lists. False means the cluster of the current wiki will be used."
+ },
+ "ReadingListsDatabase": {
+ "value": false,
+ "description": "Database to use for storing the lists.
False means use the database of the current wiki. To use a table prefix, use
'<database>-<prefix'>' format."
+ },
+ "ReadingListsCentralWiki": {
+ "value": false,
+ "description": "Database name of the central wiki. This
is unrelated to data storage (see ReadingListsDatabase for that) and only used
to identify which wiki should be used for jobs and such."
+ }
},
"Hooks": {
+ "LoadExtensionSchemaUpdates":
"MediaWiki\\Extensions\\ReadingLists\\HookHandler::onLoadExtensionSchemaUpdates",
+ "UnitTestsAfterDatabaseSetup":
"MediaWiki\\Extensions\\ReadingLists\\HookHandler::onUnitTestsAfterDatabaseSetup",
+ "UnitTestsBeforeDatabaseTeardown":
"MediaWiki\\Extensions\\ReadingLists\\HookHandler::onUnitTestsBeforeDatabaseTeardown"
},
"MessagesDirs": {
"ReadingLists": [
"i18n"
]
},
- "manifest_version": 1
+ "manifest_version": 2
}
diff --git a/phpcs.xml b/phpcs.xml
index 1b65a5f..1d01c0b 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -1,6 +1,8 @@
<?xml version="1.0"?>
<ruleset>
- <rule ref="vendor/mediawiki/mediawiki-codesniffer/MediaWiki" />
+ <rule ref="vendor/mediawiki/mediawiki-codesniffer/MediaWiki">
+ <exclude
name="MediaWiki.Commenting.FunctionComment.MissingParamComment" />
+ </rule>
<file>.</file>
<arg name="extensions" value="php,php5,inc" />
<arg name="encoding" value="utf8" />
diff --git a/sql/readinglists.sql b/sql/readinglists.sql
new file mode 100644
index 0000000..b01221f
--- /dev/null
+++ b/sql/readinglists.sql
@@ -0,0 +1,88 @@
+-- Lists.
+CREATE TABLE /*_*/reading_list (
+ rl_id INTEGER UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ -- Central ID of user.
+ rl_user_id INTEGER UNSIGNED NOT NULL,
+ -- Flag to tell apart the initial list from the rest, for UX purposes and
to forbid deleting it.
+ -- Users with more than zero lists always have exactly one default list.
+ rl_is_default TINYINT NOT NULL DEFAULT 0,
+ -- Human-readable non-unique name of the list.
+ rl_name VARCHAR(255) BINARY NOT NULL,
+ -- Description of the list.
+ rl_description VARBINARY(767) NOT NULL DEFAULT '',
+ -- List color as 3x2 hex digits.
+ rl_color VARBINARY(6) DEFAULT NULL,
+ -- List image as file name to pass to wfFindFile() or the like.
+ rl_image VARBINARY(255) DEFAULT NULL,
+ -- List icon.
+ rl_icon VARBINARY(32) DEFAULT NULL,
+ -- Creation timestamp.
+ rl_date_created BINARY(14) NOT NULL default '19700101000000',
+ -- Last modification timestamp.
+ -- This includes modifications to the reading_list record, and
modifications to sort order
+ -- of the child entries, but not modifications/additions/deletions of
child entries themselves.
+ rl_date_updated BINARY(14) NOT NULL default '19700101000000',
+ -- Deleted flag.
+ -- 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?
+
+-- List items.
+CREATE TABLE /*_*/reading_list_entry (
+ rle_id INTEGER UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ -- Reference to reading_list.rl_id.
+ rle_rl_id INTEGER UNSIGNED NOT NULL,
+ -- Central ID of user, denormalized for the benefit of the /pages/ route.
+ rle_user_id INTEGER UNSIGNED NOT NULL,
+ -- Wiki project domain.
+ -- (TODO: use a lookup table / some other way of compression?)
+ rle_project VARCHAR(255) BINARY NOT NULL,
+ -- Page title.
+ -- We can't easily use page ids due to the cross-wiki nature of the
project;
+ -- also, page ids don't age well when content is deleted/moved.
+ rle_title VARCHAR(255) BINARY NOT NULL,
+ -- Creation timestamp.
+ rle_date_created BINARY(14) NOT NULL default '19700101000000',
+ -- Last modification timestamp.
+ rle_date_updated BINARY(14) NOT NULL default '19700101000000',
+ -- Deleted flag.
+ -- 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_project, rle_title);
+-- For ensuring there are no duplicate pages on a single list.
+CREATE UNIQUE INDEX /*i*/rle_list_project_title ON /*_*/reading_list_entry
(rle_rl_id, rle_project, rle_title);
+
+-- Table for storing domains efficiently.
+CREATE TABLE /*_*/reading_list_domain (
+ rld_id INTEGER UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rld_domain VARBINARY(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/rld_domain ON /*_*/reading_list_domain (rld_domain);
+
+-- Sort keys for lists.
+-- Kept in a separate table as a typical sort operation will update the keys
for all lists
+-- and handling that with delete + insert is more convenient than with update.
+-- The table is updated on demand. It's not safe to assume that lists always
have a corresponding
+-- sortkey row or that sortkey rows always belong to a list.
+CREATE TABLE /*_*/reading_list_sortkey (
+ rls_rl_id INTEGER UNSIGNED NOT NULL PRIMARY KEY,
+ rls_index INTEGER UNSIGNED NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rls_index ON /*_*/reading_list_sortkey (rls_index);
+
+-- Sort keys for list entries.
+-- See reading_list_sortkey for details.
+CREATE TABLE /*_*/reading_list_entry_sortkey (
+ rles_rle_id INTEGER UNSIGNED NOT NULL PRIMARY KEY,
+ rles_index INTEGER UNSIGNED NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rles_index ON /*_*/reading_list_entry_sortkey (rles_index);
diff --git a/src/HookHandler.php b/src/HookHandler.php
new file mode 100644
index 0000000..02fd2c1
--- /dev/null
+++ b/src/HookHandler.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+use DatabaseUpdater;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+
+class HookHandler {
+
+ /** @var array Tables which need to be set up / torn down for tests */
+ public static $testTables = [
+ 'reading_list',
+ 'reading_list_entry',
+ 'reading_list_sortkey',
+ 'reading_list_entry_sortkey',
+ ];
+
+ /**
+ * @param DatabaseUpdater $updater
+ * @return bool
+ */
+ public static function onLoadExtensionSchemaUpdates( DatabaseUpdater
$updater ) {
+ if ( Utils::isCentralWiki() ) {
+ $baseDir = dirname( __DIR__ );
+ $updater->addExtensionTable( 'reading_list',
"$baseDir/sql/readinglists.sql" );
+ }
+ return true;
+ }
+
+ /**
+ *
+ *
+ * Setup the centralauth tables in the current DB, so we don't have
+ * to worry about rights on another database. The first time it's called
+ * we have to set the DB prefix ourselves, and reset it back to the
original
+ * so that CloneDatabase will work. On subsequent runs, the prefix is
already
+ * set up for us.
+ *
+ *
+ * @param IMaintainableDatabase $db
+ * @param string $prefix
+ */
+ public static function onUnitTestsAfterDatabaseSetup( $db, $prefix ) {
+ global $wgReadingListsCluster, $wgReadingListsDatabase;
+ $wgReadingListsCluster = false;
+ $wgReadingListsDatabase = false;
+
+ $originalPrefix = $db->tablePrefix();
+ $db->tablePrefix( $prefix );
+ if ( !$db->tableExists( 'reading_list' ) ) {
+ $baseDir = dirname( __DIR__ );
+ $db->sourceFile( "$baseDir/sql/readinglists.sql" );
+ }
+ $db->tablePrefix( $originalPrefix );
+ }
+
+ /**
+ *
+ * Cleans up tables created by onUnitTestsAfterDatabaseSetup() above
+ */
+ public static function onUnitTestsBeforeDatabaseTeardown() {
+ return; // FIXME DB closed by this point?
+ $db = wfGetDB( DB_MASTER );
+ foreach ( self::$testTables as $table ) {
+ $db->dropTable( $table );
+ }
+ }
+
+}
diff --git a/src/ReadingListRepository.php b/src/ReadingListRepository.php
new file mode 100644
index 0000000..cffcfa3
--- /dev/null
+++ b/src/ReadingListRepository.php
@@ -0,0 +1,799 @@
+<?php
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+use DBAccessObjectUtils;
+use IDBAccessObject;
+use LogicException;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IResultWrapper;
+
+/**
+ * A DAO class for reading lists.
+ *
+ * Reading lists are private and only ever visible to the owner. The class is
constructed with a
+ * user ID; unless otherwise noted, all operations are limited to
lists/entries belonging to that
+ * user. Calling with parameters inconsistent with that will result in an
error.
+ *
+ * Methods which query data will usually return a result set (as if
Database::select was called
+ * directly). Methods which modify data don't return anything. A
ReadingListRepositoryException
+ * will be thrown if the operation failed or was invalid.
+ */
+class ReadingListRepository implements IDBAccessObject, LoggerAwareInterface {
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var IDatabase */
+ private $dbr;
+
+ /** @var IDatabase */
+ private $dbw;
+
+ /** @var int|null */
+ private $userId;
+
+ /**
+ * @param int $userId Central ID of the user.
+ * @param IDatabase $dbr Database connection for reading.
+ * @param IDatabase $dbw Database connection for writing.
+ */
+ public function __construct( $userId, IDatabase $dbr, IDatabase $dbw ) {
+ $this->userId = $userId;
+ $this->dbr = $dbr;
+ $this->dbw = $dbw;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @return void
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ // setup / teardown
+
+ /**
+ * Set up the service for the given user.
+ * This is a pre-requisite for doing anything else. It will create a
default list.
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function setupForUser() {
+ if ( $this->isSetupForUser( self::READ_LOCKING ) ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-already-set-up' );
+ }
+ $this->dbw->insert(
+ 'reading_list',
+ [
+ 'rl_user_id' => $this->userId,
+ 'rl_is_default' => 1,
+ 'rl_name' => 'default',
+ 'rl_description' => '',
+ 'rl_color' => '',
+ 'rl_image' => '',
+ 'rl_icon' => '',
+ 'rl_date_created' => $this->dbw->timestamp(),
+ 'rl_date_updated' => $this->dbw->timestamp(),
+ 'rl_deleted' => 0,
+ ],
+ __METHOD__
+ );
+ $this->dbw->insert(
+ 'reading_list_sortkey',
+ [
+ 'rls_rl_id' => $this->dbw->insertId(),
+ 'rls_index' => 0,
+ ]
+ );
+ }
+
+ /**
+ * Remove all data for the given user.
+ * No other operation can be performed for the user except setup.
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function teardownForUser() {
+ if ( !$this->isSetupForUser() ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-set-up' );
+ }
+ $this->dbw->delete(
+ 'reading_list',
+ [ 'rl_user_id' => $this->userId ],
+ __METHOD__
+ );
+ $this->dbw->delete(
+ 'reading_list_entry',
+ [ 'rle_user_id' => $this->userId ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Check whether reading lists have been set up for the given user (ie.
setupForUser() was
+ * called with $userId and teardownForUser() was not called with the
same id afterwards).
+ * @param int $flags IDBAccessObject flags
+ * @return bool
+ */
+ public function isSetupForUser( $flags = 0 ) {
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions(
$flags );
+ $db = ( $index === DB_MASTER ) ? $this->dbw : $this->dbr;
+ $options = array_merge( $options, [ 'LIMIT' => 1 ] );
+ $res = $db->select(
+ 'reading_list',
+ '1',
+ [
+ 'rl_user_id' => $this->userId,
+ // It would probably be fine to just check if
the user has lists at all,
+ // but this way is extra safe against races as
setup is the only operation that
+ // creates a default list.
+ 'rl_is_default' => 1,
+ ],
+ __METHOD__,
+ $options
+ );
+ return (bool)$res->numRows();
+ }
+
+ // list CRUD
+
+ /**
+ * Create a new list.
+ * @param string $name
+ * @param string $description
+ * @param string $color
+ * @param string $image
+ * @param string $icon
+ * @return int The ID of the new list
+ * @throws ReadingListRepositoryException
+ */
+ public function addList( $name, $description = '', $color = '', $image
= '', $icon = '' ) {
+ if ( !$this->isSetupForUser( self::READ_LOCKING ) ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-set-up' );
+ }
+ $this->dbw->insert(
+ 'reading_list',
+ [
+ 'rl_user_id' => $this->userId,
+ 'rl_is_default' => 0,
+ 'rl_name' => $name,
+ 'rl_description' => $description,
+ 'rl_color' => $color,
+ 'rl_image' => $image,
+ 'rl_icon' => $icon,
+ 'rl_date_created' => $this->dbw->timestamp(),
+ 'rl_date_updated' => $this->dbw->timestamp(),
+ 'rl_deleted' => 0,
+ ],
+ __METHOD__
+ );
+ return $this->dbw->insertId();
+ }
+
+ /**
+ * Get all lists of the user.
+ * @param int $limit
+ * @param int $offset
+ * @return IResultWrapper<ReadingListRow>
+ * @throws ReadingListRepositoryException
+ */
+ public function getAllLists( $limit = 1000, $offset = 0 ) {
+ // TODO sortkeys?
+ $res = $this->dbr->select(
+ [ 'reading_list', 'reading_list_sortkey' ],
+ $this->getListFields(),
+ [
+ 'rl_user_id' => $this->userId,
+ 'rl_deleted' => 0,
+ ],
+ __METHOD__,
+ [
+ 'LIMIT' => $limit,
+ 'OFFSET' => $offset,
+ 'ORDER BY' => 'rls_index',
+ ],
+ [
+ 'reading_list_sortkey' => [ 'LEFT JOIN', 'rl_id
= rls_rl_id' ],
+ ]
+ );
+
+ if ( $res->numRows() === 0 ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-set-up' );
+ }
+ return $res;
+ }
+
+ /**
+ * Update a list.
+ * @param string $id
+ * @param string $name
+ * @param string $description
+ * @param string $color
+ * @param string $image
+ * @param string $icon
+ * @return void
+ * @throws ReadingListRepositoryException
+ * @throws LogicException
+ */
+ public function updateList( $id, $name, $description = '', $color = '',
$image = '', $icon = '' ) {
+ $this->dbw->update(
+ 'reading_list',
+ [
+ 'rl_name' => $name,
+ 'rl_description' => $description,
+ 'rl_color' => $color,
+ 'rl_image' => $image,
+ 'rl_icon' => $icon,
+ 'rl_date_updated' => $this->dbw->timestamp(),
+ ],
+ [
+ 'rl_id' => $id,
+ 'rl_user_id' => $this->userId,
+ ],
+ __METHOD__
+ );
+ if ( $this->dbw->affectedRows() ) {
+ return;
+ }
+
+ // failed; see what went wrong so we can return a useful error
message
+ /** @var ReadingListRow $row */
+ $row = $this->dbw->selectRow(
+ 'reading_list',
+ [ 'rl_user_id' ],
+ [ 'rl_id' => $id ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-no-such-list', [ $id ] );
+ } elseif ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-own-list', [ $id ] );
+ } else {
+ throw new LogicException( 'updateList failed for
unknown reason' );
+ }
+ }
+
+ /**
+ * Delete a list.
+ * @param int $id
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function deleteList( $id ) {
+ $this->dbw->update(
+ 'reading_list',
+ [ 'rl_deleted' => 1 ],
+ [
+ 'rl_id' => $id,
+ 'rl_user_id' => $this->userId,
+ // cannot delete the default list
+ 'rl_is_default' => 0,
+ ],
+ __METHOD__
+ );
+ if ( $this->dbw->affectedRows() ) {
+ return;
+ }
+
+ // failed; see what went wrong so we can return a useful error
message
+ /** @var ReadingListRow $row */
+ $row = $this->dbw->selectRow(
+ 'reading_list',
+ [ 'rl_user_id', 'rl_is_default' ],
+ [ 'rl_id' => $id ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-no-such-list', [ $id ] );
+ } elseif ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-own-list', [ $id ] );
+ } elseif ( $row->rl_is_default ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-cannot-delete-default-list' );
+ } else {
+ throw new LogicException( 'deleteList failed for
unknown reason' );
+ }
+ }
+
+ // list entry CRUD
+
+ /**
+ * Add a new page to a list.
+ * @param int $id List ID
+ * @param string $project Project identifier (typically a domain name)
+ * @param string $title Page title (in localized prefixed DBkey format)
+ * @return int The ID of the new list entry
+ * @throws ReadingListRepositoryException
+ */
+ public function addListEntry( $id, $project, $title ) {
+ // verify that the list exists and we have access to it
+ /** @var ReadingListRow $row */
+ $row = $this->dbw->selectRow(
+ 'reading_list',
+ 'rl_user_id',
+ [
+ 'rl_id' => $id,
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ if ( !$row ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-no-such-list', [ $id ] );
+ } elseif ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-own-list', [ $id ] );
+ }
+
+ $this->dbw->insert(
+ 'reading_list_entry',
+ [
+ 'rle_rl_id' => $id,
+ 'rl_user_id' => $this->userId,
+ 'rle_project' => $project,
+ 'rle_title' => $title,
+ 'rle_date_created' => $this->dbw->timestamp(),
+ 'rle_date_updated' => $this->dbw->timestamp(),
+ 'rle_deleted' => 0,
+ ],
+ __METHOD__,
+ // throw custom exception for unique constraint on
rle_rl_id + rle_project + rle_title
+ [ 'IGNORE' ]
+ );
+ if ( !$this->dbw->affectedRows() ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-duplicate-page' );
+ }
+ return $this->dbw->insertId();
+ }
+
+ /**
+ * Get the entries of one or more lists.
+ * @param array $ids
+ * @param int $limit
+ * @param int $offset
+ * @return IResultWrapper<ReadingListEntryRow>
+ * @throws ReadingListRepositoryException
+ */
+ public function getListEntries( array $ids, $limit = 1000, $offset = 0
) {
+ // TODO sortkeys?
+ // sanity check for nice error messages
+ $res = $this->dbr->select(
+ 'reading_list',
+ [ 'rl_id', 'rl_user_id', 'rl_deleted' ],
+ [ 'rl_id' => $ids ]
+ );
+ $filtered = [];
+ foreach ( $res as $row ) {
+ /** @var ReadingListRow $row */
+ if ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-not-own-list', [
$row->rl_id ] );
+ } elseif ( $row->rl_deleted ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-list-deleted', [
$row->rl_id ] );
+ }
+ $filtered[] = $row->rl_id;
+ }
+ $missing = array_diff( $ids, $filtered );
+ if ( $missing ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-no-such-list', [
$missing[0] ] );
+ }
+
+ $res = $this->dbr->select(
+ [ 'reading_list_entry', 'reading_list_entry_sortkey' ],
+ $this->getListEntryFields(),
+ [
+ 'rle_rl_id' => $ids,
+ 'rle_user' => $this->userId,
+ 'rle_deleted' => 0,
+ ],
+ __METHOD__,
+ [
+ 'LIMIT' => $limit,
+ 'OFFSET' => $offset,
+ 'ORDER BY' => 'rles_index',
+ ],
+ [
+ 'reading_list_entry_sortkey' => [ 'LEFT JOIN',
'rle_id = rles_rle_id' ],
+ ]
+ );
+
+ return $res;
+ }
+
+ /**
+ * Delete a page from a list.
+ * @param int $id
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function deleteListEntry( $id ) {
+ $this->dbw->update(
+ 'reading_list_entry',
+ [ 'rle_deleted' => 1 ],
+ [
+ 'rle_id' => $id,
+ 'rle_user_id' => $this->userId,
+ ],
+ __METHOD__
+ );
+ if ( $this->dbw->affectedRows() ) {
+ return;
+ }
+
+ // failed; see what went wrong so we can return a useful error
message
+ /** @var ReadingListEntryRow $row */
+ $row = $this->dbw->selectRow(
+ 'reading_list_entry',
+ [ 'rle_user_id' ],
+ [ 'rle_id' => $id ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-no-such-list-entry', [ $id ] );
+ } elseif ( $row->rle_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-own-list-entry', [ $id ] );
+ } else {
+ throw new LogicException( 'deleteListEntry failed for
unknown reason' );
+ }
+ }
+
+ // sorting
+
+ /**
+ * Return the ids of all lists in order.
+ * @return int[]
+ * @throws ReadingListRepositoryException
+ */
+ public function getListOrder() {
+ $ids = $this->dbr->selectFieldValues(
+ [ 'reading_list', 'reading_list_sortkey' ],
+ 'rl_id',
+ [
+ 'rl_user_id' => $this->userId,
+ 'rl_deleted' => 0,
+ ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rls_index',
+ ],
+ [
+ 'reading_list_sortkey' => [ 'LEFT JOIN', 'rl_id
= rls_rl_id' ],
+ ]
+ );
+ if ( !$ids ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-not-set-up' );
+ }
+ return $ids;
+ }
+
+ /**
+ * Update the order of lists.
+ * @param array $order A list of all reading list ids, in the desired
order.
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function setListOrder( array $order ) {
+ if ( !$order ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-empty-order' );
+ }
+
+ // Make sure the lists exist and the user owns them.
+ $res = $this->dbw->select(
+ 'reading_list',
+ [ 'rl_id', 'rl_user_id', 'rl_deleted' ],
+ [ 'rl_id' => $order ]
+ );
+ $filtered = [];
+ foreach ( $res as $row ) {
+ /** @var ReadingListRow $row */
+ if ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-not-own-list', [
$row->rl_id ] );
+ } elseif ( $row->rl_deleted ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-list-deleted', [
$row->rl_id ] );
+ }
+ $filtered[] = $row->rl_id;
+ }
+ $missing = array_diff( $order, $filtered );
+ if ( $missing ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-no-such-list', [
$missing[0] ] );
+ }
+
+ $this->dbw->deleteJoin(
+ 'reading_list_sortkey',
+ 'reading_list',
+ 'rls_rl_id',
+ 'rl_id',
+ [ 'rl_user_id' => $this->userId ]
+ );
+ $this->dbw->insert(
+ 'reading_list_sortkey',
+ array_map( function ( $id, $index ) {
+ return [
+ 'rls_rl_id' => $id,
+ 'rls_index' => $index,
+ ];
+ }, array_values( $order ), array_keys( $order ) )
+ );
+ }
+
+ /**
+ * Return the ids of all entries of the list in order.
+ * @param int $id List ID
+ * @return int[]
+ * @throws ReadingListRepositoryException
+ */
+ public function getListEntryOrder( $id ) {
+ $ids = $this->dbr->selectFieldValues(
+ [ 'reading_list_entry', 'reading_list_entry_sortkey' ],
+ 'rle_id',
+ [
+ 'rle_rl_id' => $id,
+ 'rle_user' => $this->userId,
+ 'rle_deleted' => 0,
+ ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rles_index',
+ ],
+ [
+ 'reading_list_sortkey' => [ 'LEFT JOIN',
'rle_id = rles_rle_id' ],
+ ]
+ );
+ if ( !$ids ) {
+ /** @var ReadingListRow $row */
+ $row = $this->dbr->selectRow(
+ 'reading_list',
+ [ 'rl_user_id', 'rl_deleted' ],
+ [ 'rl_id' => $id ]
+ );
+ if ( !$row ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-no-such-list', [
$row->rl_id ] );
+ } elseif ( $row->rl_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-not-own-list', [
$row->rl_id ] );
+ } elseif ( $row->rl_deleted ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-list-deleted', [
$row->rl_id ] );
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * Update the order of the entries of a list.
+ * @param int $id List ID
+ * @param array $order A list of IDs for all entries of the list, in
the desired order.
+ * @return void
+ * @throws ReadingListRepositoryException
+ */
+ public function setListEntryOrder( $id, array $order ) {
+ if ( !$order ) {
+ throw new ReadingListRepositoryException(
'readinglists-db-error-empty-order' );
+ }
+
+ // Make sure the list entries exist and the user owns them.
+ $res = $this->dbw->select(
+ 'reading_list_entry',
+ [ 'rle_id', 'rle_user' ],
+ [ 'rle_id' => $order ]
+ );
+ $filtered = [];
+ foreach ( $res as $row ) {
+ /** @var ReadingListEntryRow $row */
+ if ( $row->rle_user_id != $this->userId ) {
+ throw new ReadingListRepositoryException(
+
'readinglists-db-error-not-own-list-entry', [ $row->rle_id ] );
+ } elseif ( $row->rle_rl_id != $id ) {
+ throw new ReadingListRepositoryException(
+
'readinglists-db-error-entry-not-in-list', [ $row->rle_id ] );
+ }
+ $filtered[] = $row->rle_id;
+ }
+ $missing = array_diff( $order, $filtered );
+ if ( $missing ) {
+ throw new ReadingListRepositoryException(
+ 'readinglists-db-error-no-such-list-entry', [
$missing[0] ] );
+ }
+
+ $this->dbw->deleteJoin(
+ 'reading_list_entry_sortkey',
+ 'reading_list_entry',
+ 'rles_rle_id',
+ 'rle_id',
+ [ 'rle_rl_id' => $id ]
+ );
+ $this->dbw->insert(
+ 'reading_list_entry_sortkey',
+ array_map( function ( $id, $index ) {
+ return [
+ 'rles_rle_id' => $id,
+ 'rles_index' => $index,
+ ];
+ }, array_values( $order ), array_keys( $order ) )
+ );
+ }
+
+ /**
+ * Purge sortkeys whose lists have been deleted.
+ * Unlike most other methods in the class, this one ignores user IDs.
+ * @return void
+ */
+ public function purgeSortkeys() {
+ while ( true ) {
+ $ids = $this->dbw->selectFieldValues(
+ [ 'reading_list_entry',
'reading_list_entry_sortkey' ],
+ 'rles_rl_id',
+ [
+ 'rle_id' => null,
+ ],
+ __METHOD__,
+ [
+ 'GROUP BY' => 'rle_id',
+ 'LIMIT' => 1000,
+ ],
+ [
+ 'reading_list_entry_sortkey' => [ 'LEFT
JOIN', 'rle_id = rles_rle_id' ],
+ ]
+ );
+ if ( !$ids ) {
+ break;
+ }
+ $this->dbw->delete(
+ 'reading_list_entry_sortkey',
+ [
+ 'rles_rle_id' => $ids,
+ ]
+ );
+ }
+ }
+
+ // sync
+
+ /**
+ * Get lists and list entries that have changed since a given date.
+ * Unlike other methods this returns deleted lists / entries as well.
+ * The result will be a set of reading_list + reading_list_entry rows
where
+ * @param string $date The cutoff date in TS_MW format
+ * @return IResultWrapper<ReadingListAndEntryRow>
+ */
+ public function getListsByDateUpdated( $date ) {
+ // FIXME return entries in a separate method?
+ $res = $this->dbr->select(
+ [ 'reading_list', 'reading_list_entry' ],
+ array_merge( $this->getListFields(),
$this->getListEntryFields() ),
+ [
+ 'rl_id = rle_rl_id',
+ 'rl_user_id' => $this->userId,
+ $this->dbr->makeList( [
+ 'rl_date_updated > ' .
$this->dbr->addQuotes( $this->dbr->timestamp( $date ) ),
+ 'rle_date_updated > ' .
$this->dbr->addQuotes( $this->dbr->timestamp( $date ) ),
+ ], IDatabase::LIST_OR ),
+ ],
+ __METHOD__
+ );
+ return $res;
+ }
+
+ /**
+ * Purge all deleted lists/entries older than $before.
+ * Unlike most other methods in the class, this one ignores user IDs.
+ * @param string $before A timestamp in TS_MW format.
+ * @return void
+ */
+ public function purgeOldDeleted( $before ) {
+ // purge deleted lists
+ while ( true ) {
+ $ids = $this->dbw->selectFieldValues(
+ 'reading_list',
+ 'rl_id',
+ [
+ 'rl_deleted' => 1,
+ 'rl_date_updated < ' .
$this->dbw->addQuotes( $this->dbw->timestamp( $before ) ),
+ ],
+ __METHOD__,
+ [ 'LIMIT' => 1000 ]
+ );
+ if ( !$ids ) {
+ break;
+ }
+ $this->dbw->delete(
+ 'reading_list_entry',
+ [ 'rle_rl_id' => $ids ]
+ );
+ $this->dbw->delete(
+ 'reading_list',
+ [ 'rl_id' => $ids ]
+ );
+ }
+
+ // purge deleted list entries
+ while ( true ) {
+ $ids = $this->dbw->selectFieldValues(
+ 'reading_list_entry',
+ 'rle_id',
+ [
+ 'rle_deleted' => 1,
+ 'rle_date_updated < ' .
$this->dbw->addQuotes( $this->dbw->timestamp( $before ) ),
+ ],
+ __METHOD__,
+ [ 'LIMIT' => 1000 ]
+ );
+ if ( !$ids ) {
+ break;
+ }
+ $this->dbw->delete(
+ 'reading_list_entry',
+ [ 'rle_id' => $ids ]
+ );
+ }
+ }
+
+ // membership
+
+ /**
+ * Return all lists which contain a given page.
+ * @param string $project Project identifier (typically a domain name)
+ * @param string $title Page title (in localized prefixed DBkey format)
+ * @return IResultWrapper<ReadingListRow>
+ */
+ public function getListsByPage( $project, $title ) {
+ $res = $this->dbr->select(
+ [ 'reading_list', 'reading_list_entry' ],
+ $this->getListFields(),
+ [
+ 'rl_id = rle_rl_id',
+ 'rl_user_id' => $this->userId,
+ 'rle_project' => $project,
+ 'rle_title' => $title,
+ ],
+ __METHOD__,
+ [ 'GROUP BY' => 'rl_id' ]
+ );
+ return $res;
+ }
+
+ // helper methods
+
+ /**
+ * Get this list of reading_list fields that normally need to be
selected.
+ * @return array
+ */
+ private function getListFields() {
+ return [
+ // returning rl_user_id is pointless as lists are only
available to the owner
+ 'rl_is_default',
+ 'rl_name',
+ 'rl_description',
+ 'rl_color',
+ 'rl_image',
+ 'rl_icon',
+ 'rl_date_created',
+ 'rl_date_updated',
+ 'rl_deleted',
+ ];
+ }
+
+ /**
+ * Get this list of reading_list_entry fields that normally need to be
selected.
+ * @return array
+ */
+ private function getListEntryFields() {
+ return [
+ 'rle_rl_id',
+ // returning rle_user_id is pointless as lists are only
available to the owner
+ 'rle_project',
+ 'rle_title',
+ 'rle_date_created',
+ 'rle_date_updated',
+ 'rle_deleted',
+ ];
+ }
+
+}
diff --git a/src/ReadingListRepositoryException.php
b/src/ReadingListRepositoryException.php
new file mode 100644
index 0000000..1a54beb
--- /dev/null
+++ b/src/ReadingListRepositoryException.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+use Exception;
+use Message;
+
+/**
+ * Used by ReadingListRepository methods when performing the method would
violate some kind of
+ * constraint (e.g. trying to add an entry to a list owned by a different
user). Usually this is
+ * a client error; in some cases it could happen for otherwise sane calls due
to race conditions.
+ */
+class ReadingListRepositoryException extends Exception {
+
+ /** @var Message */
+ private $messageObject;
+
+ /**
+ * @param string $message MediaWiki message key for describing the
error.
+ * @param array $params Parameters for the message.
+ */
+ public function __construct( $message, array $params = [] ) {
+ $this->messageObject = new Message( $message, $params );
+ parent::__construct( $this->messageObject->inLanguage( 'en'
)->useDatabase( false )->plain() );
+ }
+
+ /**
+ * @return Message
+ */
+ public function getMessageObject() {
+ return $this->messageObject;
+ }
+
+}
diff --git a/src/Utils.php b/src/Utils.php
new file mode 100644
index 0000000..2b1d3a2
--- /dev/null
+++ b/src/Utils.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\DBConnRef;
+
+class Utils {
+
+ /**
+ * Get a database connection for the reading lists database.
+ * @param int $db Index of the connection to get, e.g. DB_MASTER or
DB_REPLICA.
+ * @return DBConnRef
+ */
+ public static function getDB( $db ) {
+ global $wgReadingListsCluster, $wgReadingListsDatabase;
+
+ $loadBalancerFactory =
MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $loadBalancer = $wgReadingListsCluster
+ ? $loadBalancerFactory->getExternalLB(
$wgReadingListsCluster )
+ : $loadBalancerFactory->getMainLB(
$wgReadingListsDatabase );
+ return $loadBalancer->getConnectionRef( $db, [],
$wgReadingListsDatabase );
+ }
+
+ /**
+ * Check if we are on the central wiki. ReadingLists is mostly wiki
agnostic but one wiki
+ * must be selected for things that should not be duplicated (such as
jobs and schema
+ * updates).
+ * @return bool
+ */
+ public static function isCentralWiki() {
+ global $wgReadingListsCentralWiki;
+ if ( $wgReadingListsCentralWiki === false ) {
+ return true;
+ }
+ return ( wfWikiID() === $wgReadingListsCentralWiki );
+ }
+
+}
diff --git a/src/doc/ReadingListAndEntryRow.php
b/src/doc/ReadingListAndEntryRow.php
new file mode 100644
index 0000000..0cd3ffb
--- /dev/null
+++ b/src/doc/ReadingListAndEntryRow.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * @file
+ * Documentation hack for plain objects returned by DB queries.
+ * For the benefit of IDEs only, won't be used outside phpdoc.
+ */
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+/**
+ * Result row from a join query on reading_list and reading_list_entry.
+ */
+trait ReadingListAndEntryRow {
+ use ReadingListRow, ReadingListEntryRow;
+}
diff --git a/src/doc/ReadingListEntryRow.php b/src/doc/ReadingListEntryRow.php
new file mode 100644
index 0000000..b34ff9a
--- /dev/null
+++ b/src/doc/ReadingListEntryRow.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * @file
+ * Documentation hack for plain objects returned by DB queries.
+ * For the benefit of IDEs only, won't be used outside phpdoc.
+ */
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+/**
+ * Database row for reading_list_entry.
+ * Represents a single wiki page.
+ */
+trait ReadingListEntryRow {
+
+ /** @var string Primary key. */
+ public $rle_id;
+
+ /** @var string Reference to reading_list.rl_id. */
+ public $rle_rl_id;
+
+ /** @var string Central ID of user. */
+ public $rle_user_id;
+
+ /** @var string Wiki project domain. */
+ public $rle_project;
+
+ /**
+ * Page title.
+ * We can't easily use page ids due to the cross-wiki nature of the
project;
+ * also, page ids don't age well when content is deleted/moved.
+ * @var string
+ */
+ public $rle_title;
+
+ /** @var string Creation timestamp. */
+ public $rle_date_created;
+
+ /** @var string Last modification timestamp. */
+ public $rle_date_updated;
+
+ /**
+ * Deleted flag.
+ * Entries will be hard-deleted eventually but kept around for a while
for sync.
+ * @var string
+ */
+ public $rle_deleted;
+
+}
diff --git a/src/doc/ReadingListRow.php b/src/doc/ReadingListRow.php
new file mode 100644
index 0000000..4ed2134
--- /dev/null
+++ b/src/doc/ReadingListRow.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * @file
+ * Documentation hack for plain objects returned by DB queries.
+ * For the benefit of IDEs only, won't be used outside phpdoc.
+ */
+
+namespace MediaWiki\Extensions\ReadingLists;
+
+/**
+ * Database row for reading_list.
+ * Represents a list of pages (potentially from multple wikis) plus some
display-oriented metadata.
+ */
+trait ReadingListRow {
+
+ /** @var string Primary key. */
+ public $rl_id;
+
+ /** @var string Central ID of user. */
+ public $rl_user_id;
+
+ /**
+ * Flag to tell apart the initial list from the rest, for UX purposes
and to forbid deleting it.
+ * Users with more than zero lists always have exactly one default list.
+ * @var string
+ */
+ public $rl_is_default;
+
+ /** @var string Human-readable non-unique name of the list. */
+ public $rl_name;
+
+ /** @var string Description of the list. */
+ public $rl_description;
+
+ /** @var string List color as 3x2 hex digits. */
+ public $rl_color;
+
+ /** @var string List image as file name to pass to wfFindFile() or the
like. */
+ public $rl_image;
+
+ /** @var string List icon. */
+ public $rl_icon;
+
+ /** @var string Creation timestamp. */
+ public $rl_date_created;
+
+ /**
+ * Last modification timestamp.
+ * This includes modifications to the reading_list record, and
modifications to sort order
+ * of the child entries, but not modifications/additions/deletions of
child entries themselves.
+ * @var string
+ */
+ public $rl_date_updated;
+
+ /**
+ * Deleted flag.
+ * Lists will be hard-deleted eventually but kept around for a while
for sync.
+ * @var string
+ */
+ public $rl_deleted;
+
+}
--
To view, visit https://gerrit.wikimedia.org/r/365987
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: Ifb3c7538feb377e52582cf21131b5337830dde4f
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