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

Reply via email to