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

Change subject: New Data interfaces
......................................................................


New Data interfaces

Change-Id: Ie4ee268a2c42c9066a4c8667f01dce0b2db6799c
---
M extension.json
M i18n/api/en.json
M i18n/api/qqq.json
A src/Api/WatchlistStore.php
M src/Data/DatabaseReader.php
M src/Data/DatabaseWriter.php
A src/Data/FieldType.php
A src/Data/Filter.php
A src/Data/Filter/Boolean.php
A src/Data/Filter/Date.php
A src/Data/Filter/ListValue.php
A src/Data/Filter/Numeric.php
A src/Data/Filter/Range.php
A src/Data/Filter/StringValue.php
A src/Data/Filter/TemplateTitle.php
A src/Data/Filter/Title.php
A src/Data/FilterFactory.php
A src/Data/FilterFinder.php
A src/Data/Filterer.php
A src/Data/IPrimaryDataProvider.php
M src/Data/IReader.php
A src/Data/IRecord.php
A src/Data/ISecondaryDataProvider.php
A src/Data/ITrimmer.php
A src/Data/LimitOffsetTrimmer.php
A src/Data/Reader.php
A src/Data/ReaderParams.php
A src/Data/Record.php
A src/Data/RecordConverter.php
M src/Data/ResultSet.php
A src/Data/Schema.php
A src/Data/SecondaryDataProvider.php
A src/Data/Sort.php
A src/Data/Sorter.php
A src/Data/Watchlist/PrimaryDataProvider.php
A src/Data/Watchlist/Reader.php
A src/Data/Watchlist/Record.php
A src/Data/Watchlist/Schema.php
A src/Data/Watchlist/SecondaryDataProvider.php
A src/Data/Watchlist/Store.php
A src/StoreApiBase.php
A tests/phpunit/Data/Filter/BooleanTest.php
A tests/phpunit/Data/Filter/DateTest.php
A tests/phpunit/Data/Filter/ListValueTest.php
A tests/phpunit/Data/Filter/NumericTest.php
A tests/phpunit/Data/Filter/StringValueTest.php
A tests/phpunit/Data/Filter/TemplateTitleTest.php
A tests/phpunit/Data/Filter/TitleTest.php
A tests/phpunit/Data/FiltererTest.php
A tests/phpunit/Data/LimitOffsetTrimmerTest.php
A tests/phpunit/Data/ReaderParamsTest.php
A tests/phpunit/Data/SorterTest.php
A tests/phpunit/Data/Watchlist/ReaderTest.php
53 files changed, 2,545 insertions(+), 10 deletions(-)

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



diff --git a/extension.json b/extension.json
index be5502c..4e4d878 100644
--- a/extension.json
+++ b/extension.json
@@ -27,7 +27,8 @@
                "bs-titlequery-store": "BSApiTitleQueryStore",
                "bs-ping-tasks": "BSApiPingTasks",
                "bs-upload-license-store": "BSApiUploadLicenseStore",
-               "bs-category-treestore": "BSApiCategoryTreeStore"
+               "bs-category-treestore": "BSApiCategoryTreeStore",
+               "bs-watchlist-store": "BlueSpice\\Api\\WatchlistStore"
        },
        "ResourceModules": {
                "ext.bluespice": {
diff --git a/i18n/api/en.json b/i18n/api/en.json
index 49e5b44..b8991a3 100644
--- a/i18n/api/en.json
+++ b/i18n/api/en.json
@@ -62,5 +62,6 @@
        "apihelp-bs-task-param-examples": "Show examples of task parameters",
        "bs-api-task-taskData-page-id": "The ID of an existing wiki page",
        "bs-api-task-taskData-page-title": "A valid title of a wiki page",
-       "bs-api-task-wikipagetasks-taskData-categories": "Array of strings. 
Values need to be valid category names."
+       "bs-api-task-wikipagetasks-taskData-categories": "Array of strings. 
Values need to be valid category names.",
+       "apihelp-bs-watchlist-store-summary": "Lists all watchlist entries. 
Allows sorting, filtering and pagination. Implements store parameters."
 }
diff --git a/i18n/api/qqq.json b/i18n/api/qqq.json
index c1e88df..d14444a 100644
--- a/i18n/api/qqq.json
+++ b/i18n/api/qqq.json
@@ -64,5 +64,6 @@
        "apihelp-bs-task-param-examples": 
"{{doc-apihelp-param|bs-task|examples}}",
        "bs-api-task-taskData-page-id": "Description of 
<code>taskData.page_id</code> field",
        "bs-api-task-taskData-page-title": "Description of 
<code>taskData.page_title</code> field",
-       "bs-api-task-wikipagetasks-taskData-categories": "Description of 
<code>taskData.categories</code> field in WikiPageTasks API"
+       "bs-api-task-wikipagetasks-taskData-categories": "Description of 
<code>taskData.categories</code> field in WikiPageTasks API",
+       "apihelp-bs-watchlist-store-summary": 
"{{doc-apihelp-description|bs-watchlist-store}}"
 }
diff --git a/src/Api/WatchlistStore.php b/src/Api/WatchlistStore.php
new file mode 100644
index 0000000..fb883e9
--- /dev/null
+++ b/src/Api/WatchlistStore.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace BlueSpice\Api;
+
+class WatchlistStore extends \BlueSpice\StoreApiBase {
+
+       protected function makeDataStore() {
+               return new \BlueSpice\Data\Watchlist\Store( $this->getContext() 
);
+       }
+}
\ No newline at end of file
diff --git a/src/Data/DatabaseReader.php b/src/Data/DatabaseReader.php
index 9c066f5..856bb4e 100644
--- a/src/Data/DatabaseReader.php
+++ b/src/Data/DatabaseReader.php
@@ -2,19 +2,22 @@
 
 namespace BlueSpice\Data;
 
-abstract class DatabaseReader implements IReader {
+abstract class DatabaseReader extends Reader {
 
        /**
         *
-        * @var \DatabaseBase
+        * @var \Wikimedia\Rdbms\IDatabase
         */
        protected $db = null;
 
        /**
         *
-        * @param \LoadBalancer $loadBalancer
+        * @param \Wikimedia\Rdbms\LoadBalancer $loadBalancer
+        * @param \IContextSource $context
+        * @param \Config $config
         */
-       public function __construct( $loadBalancer ) {
+       public function __construct( $loadBalancer, \IContextSource $context = 
null, \Config $config = null ) {
+               parent::__construct( $context, $config );
                $this->db = $loadBalancer->getConnection( DB_REPLICA );
        }
 }
diff --git a/src/Data/DatabaseWriter.php b/src/Data/DatabaseWriter.php
index 5e4fb98..979b2cf 100644
--- a/src/Data/DatabaseWriter.php
+++ b/src/Data/DatabaseWriter.php
@@ -6,7 +6,7 @@
 
        /**
         *
-        * @var \DatabaseBase
+        * @var \Wikimedia\Rdbms\IDatabase
         */
        protected $db = null;
 
diff --git a/src/Data/FieldType.php b/src/Data/FieldType.php
new file mode 100644
index 0000000..fb32551
--- /dev/null
+++ b/src/Data/FieldType.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class FieldType {
+       /**
+        * https://docs.sencha.com/extjs/4.2.1/#!/api/Ext.data.Field
+        */
+       const AUTO = 'auto';
+       const BOOLEAN = 'boolean';
+       const DATE = 'date';
+       const FLOAT = 'float';
+       const INT = 'int';
+       const STRING = 'string';
+}
diff --git a/src/Data/Filter.php b/src/Data/Filter.php
new file mode 100644
index 0000000..ea87ceb
--- /dev/null
+++ b/src/Data/Filter.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace BlueSpice\Data;
+
+abstract class Filter {
+       const COMPARISON_EQUALS = 'eq';
+       const COMPARISON_NOT_EQUALS = 'neq';
+
+       const KEY_TYPE = 'type';
+       const KEY_COMPARISON = 'comparison';
+       const KEY_FIELD = 'field';
+       const KEY_VALUE = 'value';
+
+       /**
+        *
+        * @var string
+        */
+       protected $field = '';
+
+       /**
+        *
+        * @var mixed
+        */
+       protected $value = null;
+
+       /**
+        *
+        * @var string
+        */
+       protected $comparison = '';
+
+       /**
+        *
+        * @param array $params
+        */
+       public function __construct( $params ) {
+               $this->field = $params[self::KEY_FIELD];
+               $this->value = $params[self::KEY_VALUE];
+               $this->comparison = $params[self::KEY_COMPARISON];
+       }
+
+       /**
+        *
+        * @return string
+        */
+       public function getField() {
+               return $this->field;
+       }
+
+       /**
+        *
+        * @return mixed
+        */
+       public function getValue() {
+               return $this->value;
+       }
+
+       /**
+        *
+        * @return string
+        */
+       public function getComparison() {
+               return $this->comparison;
+       }
+
+       /**
+        *
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       public function matches( $dataSet ) {
+               return $this->doesMatch( $dataSet );
+       }
+
+
+       /**
+        *
+        * @param stdClass[]|array[] $filters
+        * @return Filter[]
+        */
+       public static function newCollectionFromArray( $filters ) {
+               $filterObjects = [];
+               foreach( $filters as $filter ) {
+                       if( is_object(  $filter ) ) {
+                               $filter = (array) $filter;
+                       }
+                       $filterObjects[] = FilterFactory::newFromArray( $filter 
);
+               }
+
+               return $filterObjects;
+       }
+
+       protected abstract function doesMatch( $dataSet );
+}
diff --git a/src/Data/Filter/Boolean.php b/src/Data/Filter/Boolean.php
new file mode 100644
index 0000000..22fa99f
--- /dev/null
+++ b/src/Data/Filter/Boolean.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+use BlueSpice\Data\Filter;
+
+class Boolean extends Filter {
+
+       /**
+        * Performs filtering based on given filter of type bool on a dataset
+        *
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       protected function doesMatch( $dataSet ) {
+               $fieldValue = $dataSet->get( $this->getField() );
+               $filterValue = $this->getValue();
+
+               switch( $this->getComparison() ) {
+                       case self::COMPARISON_EQUALS:
+                               return $fieldValue == $filterValue;
+                       case self::COMPARISON_NOT_EQUALS:
+                               return $fieldValue != $filterValue;
+               }
+               return  false;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filter/Date.php b/src/Data/Filter/Date.php
new file mode 100644
index 0000000..e45dc37
--- /dev/null
+++ b/src/Data/Filter/Date.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+class Date extends Range {
+
+       /**
+        * Performs filtering based on given filter of type date on a dataset
+        * "Ext.ux.grid.filter.DateFilter" by default sends filter value in 
format
+        * of m/d/Y
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       protected function doesMatch( $dataSet ) {
+               $filterValue = strtotime( $this->getValue() ); // Format: 
"m/d/Y"
+               $fieldValue = strtotime( $dataSet->get( $this->getField() ) ); 
// Format "YmdHis", or something else...
+
+               switch( $this->getComparison() ) {
+                       case self::COMPARISON_GREATER_THAN:
+                               return $fieldValue > $filterValue;
+                       case self::COMPARISON_LOWER_THAN:
+                               return $fieldValue < $filterValue;
+                       case self::COMPARISON_EQUALS:
+                               //We need to normalise the date on day-level
+                               $fieldValue = strtotime(
+                                       date( 'm/d/Y', $fieldValue )
+                               );
+                               return $fieldValue === $filterValue;
+               }
+               return true;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filter/ListValue.php b/src/Data/Filter/ListValue.php
new file mode 100644
index 0000000..00bc7ca
--- /dev/null
+++ b/src/Data/Filter/ListValue.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+use BlueSpice\Data\Filter;
+
+class ListValue extends Filter {
+
+       /**
+        * Performs list filtering based on given filter of type array on a 
dataset
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       protected function doesMatch( $dataSet ) {
+               if( !is_array( $this->getValue() ) ) {
+                       return true; //TODO: Warning
+               }
+               $fieldValues = $dataSet->get( $this->getField() );
+               if( empty( $fieldValues ) ) {
+                       return false;
+               }
+
+               $intersection = array_intersect( $fieldValues, 
$this->getValue() );
+               if( empty( $intersection ) ) {
+                       return false;
+               }
+               return true;
+       }
+}
diff --git a/src/Data/Filter/Numeric.php b/src/Data/Filter/Numeric.php
new file mode 100644
index 0000000..0d84024
--- /dev/null
+++ b/src/Data/Filter/Numeric.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+class Numeric extends Range {
+       /**
+        * Performs numeric filtering based on given filter of type integer on a
+        * dataset
+        *
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       protected function doesMatch( $dataSet ) {
+               if( !is_numeric( $this->getValue() ) ) {
+                       return true; //TODO: Warning
+               }
+               $fieldValue = (int) $dataSet->get( $this->getField() );
+               $filterValue = (int) $this->getValue();
+
+               switch( $this->getComparison() ) {
+                       case self::COMPARISON_GREATER_THAN:
+                               return $fieldValue > $filterValue;
+                       case self::COMPARISON_LOWER_THAN:
+                               return $fieldValue < $filterValue;
+                       case self::COMPARISON_EQUALS:
+                               return $fieldValue === $filterValue;
+                       case self::COMPARISON_NOT_EQUALS:
+                               return $fieldValue !== $filterValue;
+               }
+               return true;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filter/Range.php b/src/Data/Filter/Range.php
new file mode 100644
index 0000000..b85b724
--- /dev/null
+++ b/src/Data/Filter/Range.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+use BlueSpice\Data\Filter;
+
+abstract class Range extends Filter {
+       const COMPARISON_LOWER_THAN = 'lt';
+       const COMPARISON_GREATER_THAN = 'gt';
+}
\ No newline at end of file
diff --git a/src/Data/Filter/StringValue.php b/src/Data/Filter/StringValue.php
new file mode 100644
index 0000000..d569ab6
--- /dev/null
+++ b/src/Data/Filter/StringValue.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+use BlueSpice\Data\Filter;
+
+/**
+ * Class name "String" is reserved
+ */
+class StringValue extends Filter {
+       const COMPARISON_STARTS_WITH = 'sw';
+       const COMPARISON_ENDS_WITH = 'ew';
+       const COMPARISON_CONTAINS = 'ct';
+       const COMPARISON_NOT_CONTAINS = 'nct';
+
+       /**
+        * Performs string filtering based on given filter of type string on a
+        * dataset
+        * @param \BlueSpice\Data\Record $dataSet
+        */
+       protected function doesMatch( $dataSet ) {
+               $fieldValue = $dataSet->get( $this->getField() );
+
+               return \BsStringHelper::filter( $this->getComparison(), 
$fieldValue, $this->getValue() );
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filter/TemplateTitle.php 
b/src/Data/Filter/TemplateTitle.php
new file mode 100644
index 0000000..0ef445b
--- /dev/null
+++ b/src/Data/Filter/TemplateTitle.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+class TemplateTitle extends Title {
+       protected function getDefaultTitleNamespace() {
+               return NS_TEMPLATE;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filter/Title.php b/src/Data/Filter/Title.php
new file mode 100644
index 0000000..b4d0187
--- /dev/null
+++ b/src/Data/Filter/Title.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace BlueSpice\Data\Filter;
+
+class Title extends Range {
+
+       /**
+        * Performs string filtering based on given filter of type Title on a
+        * dataset
+        *
+        * @param \BlueSpice\Data\Record $dataSet
+        * @return boolean
+        */
+       protected function doesMatch( $dataSet ) {
+               if( !is_string( $this->getValue() ) ) {
+                       return true; //TODO: Warning
+               }
+               $fieldValue = \Title::newFromText(
+                       $dataSet->get($this->getField() ),
+                       $this->getDefaultTitleNamespace()
+               );
+               $filterValue = \Title::newFromText(
+                       $this->getValue(),
+                       $this->getDefaultTitleNamespace()
+               );
+
+               switch( $this->getComparison() ) {
+                       case self::COMPARISON_GREATER_THAN:
+                               return \Title::compare( $fieldValue, 
$filterValue ) > 0;
+                       case self::COMPARISON_LOWER_THAN:
+                               return \Title::compare( $fieldValue, 
$filterValue ) < 0;
+                       case self::COMPARISON_EQUALS:
+                               return \Title::compare( $fieldValue, 
$filterValue ) == 0;
+                       case self::COMPARISON_NOT_EQUALS:
+                               return \Title::compare( $fieldValue, 
$filterValue ) != 0;
+               }
+               return true;
+       }
+
+       protected function getDefaultTitleNamespace() {
+               return NS_MAIN;
+       }
+
+}
+
diff --git a/src/Data/FilterFactory.php b/src/Data/FilterFactory.php
new file mode 100644
index 0000000..40544d4
--- /dev/null
+++ b/src/Data/FilterFactory.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class FilterFactory {
+       /**
+        *
+        * @var array
+        */
+       public static $typeMap = [
+               'string' => 'BlueSpice\Data\Filter\StringValue',
+               'date'=> 'BlueSpice\Data\Filter\Date',
+               #'datetime'=> 'BlueSpice\Data\Filter\DateTime',
+               'boolean'=> 'BlueSpice\Data\Filter\Boolean',
+               'numeric' => 'BlueSpice\Data\Filter\Numeric',
+               'title' => 'BlueSpice\Data\Filter\Title',
+               'templatetitle' => 'BlueSpice\Data\Filter\TemplateTitle',
+               'list' => 'BlueSpice\Data\Filter\ListValue'
+       ];
+
+       /**
+        *
+        * @param array $filter
+        * @return \BlueSpice\Data\Filter
+        * @throws \UnexpectedValueException
+        */
+       public static function newFromArray( $filter ) {
+               if( isset( self::$typeMap[$filter[Filter::KEY_TYPE]]) ) {
+                       return new self::$typeMap[$filter[Filter::KEY_TYPE]]( 
$filter );
+               }
+               else {
+                       throw new \UnexpectedValueException(
+                               "No filter class for 
'{$filter[Filter::KEY_TYPE]}' available!"
+                       );
+               }
+       }
+}
+
+
diff --git a/src/Data/FilterFinder.php b/src/Data/FilterFinder.php
new file mode 100644
index 0000000..f585982
--- /dev/null
+++ b/src/Data/FilterFinder.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class FilterFinder {
+
+       /**
+        *
+        * @var Filter[]
+        */
+       protected $filters = [];
+
+       /**
+        *
+        * @param Filter[] $filters
+        */
+       public function __construct( $filters ) {
+               $this->filters = $filters;
+       }
+
+       /**
+        *
+        * @param string $fieldName
+        * @return Filter|null
+        */
+       public function findByField( $fieldName ) {
+               foreach( $this->filters as $filter ) {
+                       if( $filter->getField() === $fieldName ) {
+                               return $filter;
+                       }
+               }
+               return null;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Filterer.php b/src/Data/Filterer.php
new file mode 100644
index 0000000..0b2b354
--- /dev/null
+++ b/src/Data/Filterer.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class Filterer {
+
+       /**
+        *
+        * @var Filter[]
+        */
+       protected $filters = null;
+
+       /**
+        *
+        * @param Filter $filters
+        */
+       public function __construct( $filters ) {
+               $this->filters = $filters;
+       }
+
+       /**
+        *
+        * @param \BlueSpice\Data\Record[] $data
+        * @return \BlueSpice\Data\Record[]
+        */
+       public function filter( $data ) {
+               $filteredData = array_filter( $data, function ( $aDataSet ) {
+                               return $this->matchFilter( $aDataSet );
+                       }
+               );
+
+               return array_values( $filteredData );
+       }
+
+       /**
+        *
+        * @param object $dataSet
+        * @return boolean
+        */
+       protected function matchFilter( $dataSet ) {
+               foreach( $this->filters as $filter ) {
+                       //If just one of these filters does not apply, the 
dataset needs
+                       //to be removed
+                       if( !$filter->matches( $dataSet ) ) {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+}
\ No newline at end of file
diff --git a/src/Data/IPrimaryDataProvider.php 
b/src/Data/IPrimaryDataProvider.php
new file mode 100644
index 0000000..5871aab
--- /dev/null
+++ b/src/Data/IPrimaryDataProvider.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace BlueSpice\Data;
+
+interface IPrimaryDataProvider {
+
+       /**
+        *
+        * @param string $query Special simple filter that aims at one specific
+        * field that the DataProvider needs to define.
+        * @param Filter[] $preFilters Complete set of filters that will also be
+        * applied later during the process by the "Filterer" step. Having it 
here
+        * allows us to prefilter and tweak performance
+        * @return \BlueSpice\Data\Record[]
+        */
+       public function makeData( $query = '', $preFilters = [] );
+}
\ No newline at end of file
diff --git a/src/Data/IReader.php b/src/Data/IReader.php
index f321a26..2e8914f 100644
--- a/src/Data/IReader.php
+++ b/src/Data/IReader.php
@@ -10,4 +10,10 @@
         * @return ResultSet
         */
        public function read( $params );
+
+       /**
+        * @return Schema Column definition compatible to
+        * https://docs.sencha.com/extjs/4.2.1/#!/api/Ext.grid.Panel-cfg-columns
+        */
+       public function getSchema();
 }
\ No newline at end of file
diff --git a/src/Data/IRecord.php b/src/Data/IRecord.php
new file mode 100644
index 0000000..fa2b839
--- /dev/null
+++ b/src/Data/IRecord.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace BlueSpice\Data;
+
+interface IRecord {
+
+       /**
+        *
+        * @param string $fieldName
+        * @param mixed $default
+        */
+       public function get( $fieldName, $default = null );
+
+       /**
+        *
+        * @param string $fieldName
+        * @param mixed $value
+        */
+       public function set( $fieldName, $value );
+
+       /**
+        * @return \stdClass
+        */
+       public function getData();
+}
diff --git a/src/Data/ISecondaryDataProvider.php 
b/src/Data/ISecondaryDataProvider.php
new file mode 100644
index 0000000..11a75c7
--- /dev/null
+++ b/src/Data/ISecondaryDataProvider.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace BlueSpice\Data;
+
+interface ISecondaryDataProvider {
+
+       /**
+        *
+        * @param \BlueSpice\Data\Record[] $dataSets
+        * @return \BlueSpice\Data\Record[]
+        */
+       public function extend( $dataSets );
+}
\ No newline at end of file
diff --git a/src/Data/ITrimmer.php b/src/Data/ITrimmer.php
new file mode 100644
index 0000000..cd1e60f
--- /dev/null
+++ b/src/Data/ITrimmer.php
@@ -0,0 +1,13 @@
+<?php
+
+ namespace BlueSpice\Data;
+
+ interface ITrimmer {
+
+        /**
+         *
+         * @param \BlueSpice\Data\Record[] $dataSets
+         * @return \BlueSpice\Data\Record[]
+         */
+        public function trim( $dataSets );
+}
diff --git a/src/Data/LimitOffsetTrimmer.php b/src/Data/LimitOffsetTrimmer.php
new file mode 100644
index 0000000..0b3e1e2
--- /dev/null
+++ b/src/Data/LimitOffsetTrimmer.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class LimitOffsetTrimmer implements ITrimmer {
+
+       /**
+        *
+        * @var int
+        */
+       protected $limit = 25;
+
+       /**
+        *
+        * @var int
+        */
+       protected $offset = 0;
+
+       public function __construct( $limit = 25, $offset = 0 ) {
+               $this->limit = $limit;
+               $this->offset = $offset;
+       }
+
+       /**
+        *
+        * @param \BlueSpice\Data\Record[] $dataSets
+        * @return \BlueSpice\Data\Record[]
+        */
+       public function trim( $dataSets ) {
+               $total = count( $dataSets );
+               $end = $this->limit + $this->offset;
+
+               if( $end > $total || $end === 0 ) {
+                       $end = $total;
+               }
+
+               $trimmedData = [];
+               for( $i = $this->offset; $i < $end; $i++ ) {
+                       $trimmedData[] = $dataSets[$i];
+               }
+
+               return array_values( $trimmedData );
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Reader.php b/src/Data/Reader.php
new file mode 100644
index 0000000..05621d2
--- /dev/null
+++ b/src/Data/Reader.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace BlueSpice\Data;
+
+use \BlueSpice\Data\IReader;
+
+abstract class Reader implements IReader {
+
+       /**
+        *
+        * @var \IContextSource
+        */
+       private $context = null;
+
+       /**
+        *
+        * @var \Config
+        */
+       protected $config = null;
+
+       /**
+        *
+        * @param \IContextSource $context
+        * @param \Config $config
+        */
+       public function __construct( \IContextSource $context = null, \Config 
$config = null ) {
+               $this->context = $context;
+               if( $this->context === null ) {
+                       $this->context = \RequestContext::getMain();
+               }
+
+               $this->config = $config;
+               if( $this->config === null ) {
+                       $this->config = 
\MediaWiki\MediaWikiServices::getInstance()->getMainConfig();
+               }
+       }
+
+       /**
+        *
+        * @return \User
+        */
+       protected function getUser() {
+               return $this->context->getUser();
+       }
+
+       /**
+        *
+        * @return \Title
+        */
+       protected function getTitle() {
+               return $this->context->getTitle();
+       }
+
+       /**
+        *
+        * @param ReaderParams $params
+        * @return ResultSet
+        */
+       public function read( $params ) {
+               $primaryDataProvider = $this->makePrimaryDataProvider( $params 
);
+               $dataSets = $primaryDataProvider->makeData( 
$params->getQuery(), $params->getFilter() );
+
+               $filterer = $this->makeFilterer( $params );
+               $dataSets = $filterer->filter( $dataSets );
+               $total = count( $dataSets );
+
+               $sorter = $this->makeSorter( $params );
+               $dataSets = $sorter->sort(
+                       $dataSets,
+                       $this->getSchema()->getUnsortableFields()
+               );
+
+               $trimmer = $this->makeTrimmer( $params );
+               $dataSets = $trimmer->trim( $dataSets );
+
+               $secondaryDataProvider = $this->makeSecondaryDataProvider();
+               $dataSets = $secondaryDataProvider->extend( $dataSets );
+
+               $resultSet = new ResultSet( $dataSets, $total  );
+               return $resultSet;
+       }
+
+       /**
+        * @param ReaderParams $params
+        * @return IPrimaryDataProvider
+        */
+       protected abstract function makePrimaryDataProvider( $params );
+
+       /**
+        * @param ReaderParams $params
+        * @return Filterer
+        */
+       protected function makeFilterer( $params ) {
+               return new Filterer( $params->getFilter() );
+       }
+
+       /**
+        * @param ReaderParams $params
+        * @return Sorter
+        */
+       protected function makeSorter( $params ) {
+               return new Sorter( $params->getSort() );
+       }
+
+       /**
+        * @param ReaderParams $params
+        * @return ITrimmer
+        */
+       protected function makeTrimmer( $params ) {
+               return new LimitOffsetTrimmer(
+                               $params->getLimit(),
+                               $params->getStart()
+               );
+       }
+
+       /**
+        * @return ISecondaryDataProvider
+        */
+       protected abstract function makeSecondaryDataProvider();
+}
\ No newline at end of file
diff --git a/src/Data/ReaderParams.php b/src/Data/ReaderParams.php
new file mode 100644
index 0000000..5265c78
--- /dev/null
+++ b/src/Data/ReaderParams.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace BlueSpice\Data;
+
+use BlueSpice\Data\Sort;
+use BlueSpice\Data\Filter;
+
+class ReaderParams {
+
+       /**
+        * For pre filtering
+        * @var string
+        */
+       protected $query = '';
+
+       /**
+        * For paging
+        * @var int
+        */
+       protected $start = 0;
+
+       /**
+        * For paging
+        * @var int
+        */
+       protected $limit = 25;
+
+       /**
+        *
+        * @var Sort[]
+        */
+       protected $sort = [];
+
+       /**
+        *
+        * @var Filter[]
+        */
+       protected $filter = [];
+
+       /**
+        *
+        * @param array $params
+        */
+       public function __construct( $params = [] ) {
+               $this->setIfAvailable( $this->query, $params, 'query' );
+               $this->setIfAvailable( $this->start, $params, 'start' );
+               $this->setIfAvailable( $this->limit, $params, 'limit' );
+               $this->setSort( $params );
+               $this->setFilter( $params );
+       }
+
+       private function setIfAvailable( &$property, $source, $field ) {
+               if( isset( $source[$field] ) ) {
+                       $property = $source[$field];
+               }
+       }
+
+       /**
+        * Getter for "limit" param
+        * @return int The "limit" parameter
+        */
+       public function getLimit() {
+               return $this->limit;
+       }
+
+       /**
+        * Getter for "start" param
+        * @return int The "start" parameter
+        */
+       public function getStart() {
+               //TODO: mabye this can be calculated from "page" and "limit"; 
Examine behavior of Ext.data.Store / Ext.data.Proxy
+               return $this->start;
+       }
+
+       /**
+        * Getter for "sort" param
+        * @return Sort[]
+        */
+       public function getSort() {
+               return $this->sort;
+       }
+
+       /**
+        * Getter for "query" param
+        * @return string The "query" parameter
+        */
+       public function getQuery() {
+               return $this->query;
+       }
+
+       /**
+        * Getter for "filter" param
+        * @return Filter[]
+        */
+       public function getFilter() {
+               return $this->filter;
+       }
+
+       protected function setSort( $params ) {
+               if( !isset( $params['sort'] ) || !is_array(  $params['sort'] ) 
) {
+                       return;
+               }
+
+               $this->sort = Sort::newCollectionFromArray( $params['sort'] );
+       }
+
+       protected function setFilter( $params ) {
+               if( !isset( $params['filter'] ) || !is_array(  
$params['filter'] ) ) {
+                       return;
+               }
+               $this->filter = Filter::newCollectionFromArray( 
$params['filter'] );
+       }
+
+}
\ No newline at end of file
diff --git a/src/Data/Record.php b/src/Data/Record.php
new file mode 100644
index 0000000..bc3bc06
--- /dev/null
+++ b/src/Data/Record.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class Record implements IRecord, \JsonSerializable {
+
+       /**
+        *
+        * @var \stdClass
+        */
+       protected $dataSet = null;
+
+       /**
+        *
+        * @param \stdClass $dataSet
+        */
+       public function __construct( $dataSet ) {
+               $this->dataSet = $dataSet;
+       }
+
+       /**
+        *
+        * @param string $fieldName
+        * @param mixed $default
+        * @return mixed
+        */
+       public function get( $fieldName, $default = null ) {
+               if( isset( $this->dataSet->{$fieldName} ) ) {
+                       return $this->dataSet->{$fieldName};
+               }
+               return $default;
+       }
+
+       /**
+        *
+        * @param string $fieldName
+        * @param mixed $value
+        */
+       public function set( $fieldName, $value ) {
+               $this->dataSet->{$fieldName} = $value;
+       }
+
+       /**
+        *
+        * @return array
+        */
+       public function jsonSerialize() {
+               return (array)$this->dataSet;
+       }
+
+       /**
+        *
+        * @return \stdClass
+        */
+       public function getData() {
+               return $this->dataSet;
+       }
+
+}
+
diff --git a/src/Data/RecordConverter.php b/src/Data/RecordConverter.php
new file mode 100644
index 0000000..6b8cb15
--- /dev/null
+++ b/src/Data/RecordConverter.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class RecordConverter {
+
+       /**
+        *
+        * @var Record[]
+        */
+       protected $records = [];
+
+       /**
+        *
+        * @param Record[] $records
+        */
+       public function __construct( $records ) {
+               $this->records = $records;
+       }
+
+       public function convertToRawData() {
+               $rawData = [];
+               foreach( $this->records as $record ) {
+                       $rawData[] = $record->getData();
+               }
+               return $rawData;
+       }
+}
diff --git a/src/Data/ResultSet.php b/src/Data/ResultSet.php
index c7e371e..f4fbf60 100644
--- a/src/Data/ResultSet.php
+++ b/src/Data/ResultSet.php
@@ -6,7 +6,7 @@
 
        /**
         *
-        * @var \stdClass[]
+        * @var \BlueSpice\Data\Record[]
         */
        protected $records = [];
 
@@ -18,7 +18,7 @@
 
        /**
         *
-        * @param \stdClass[] $records
+        * @param \BlueSpice\Data\Record[] $records
         * @param int $total
         */
        public function __construct( $records, $total ) {
@@ -26,10 +26,18 @@
                $this->total = $total;
        }
 
+       /**
+        *
+        * @return \BlueSpice\Data\Record[]
+        */
        public function getRecords() {
                return $this->records;
        }
 
+       /**
+        *
+        * @return int
+        */
        public function getTotal() {
                return $this->total;
        }
diff --git a/src/Data/Schema.php b/src/Data/Schema.php
new file mode 100644
index 0000000..a59990f
--- /dev/null
+++ b/src/Data/Schema.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class Schema extends \ArrayObject {
+       const FILTERABLE = 'filterable';
+       const SORTABLE = 'sortable';
+       const TYPE = 'type';
+
+       /**
+        * @return string[]
+        */
+       public function getUnsortableFields() {
+               $unsortableFields = [];
+               foreach( $this as $fieldName => $fieldDef ) {
+                       if( $this->fieldIsSortable( $fieldDef ) ) {
+                               continue;
+                       }
+
+                       $unsortableFields[] = $fieldName;
+               }
+
+               return $unsortableFields;
+       }
+
+       protected function fieldIsSortable( $fieldDef ) {
+               if( !isset( $fieldDef[self::SORTABLE] ) ) {
+                       return false;
+               }
+
+               return $fieldDef[self::SORTABLE];
+       }
+
+}
\ No newline at end of file
diff --git a/src/Data/SecondaryDataProvider.php 
b/src/Data/SecondaryDataProvider.php
new file mode 100644
index 0000000..32df422
--- /dev/null
+++ b/src/Data/SecondaryDataProvider.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace BlueSpice\Data;
+
+use \BlueSpice\Data\ISecondaryDataProvider;
+
+abstract class SecondaryDataProvider implements ISecondaryDataProvider {
+       public function extend( $dataSets ) {
+               foreach( $dataSets as &$dataSet ) {
+                       $this->doExtend( $dataSet );
+               }
+
+               return $dataSets;
+       }
+
+       protected abstract function doExtend( &$dataSet );
+
+}
\ No newline at end of file
diff --git a/src/Data/Sort.php b/src/Data/Sort.php
new file mode 100644
index 0000000..d392e11
--- /dev/null
+++ b/src/Data/Sort.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class Sort {
+
+       const ASCENDING = 'ASC';
+       const DESCENDING = 'DESC';
+
+       protected $property = '';
+
+       protected $direction = '';
+
+       /**
+        *
+        * @param string $property
+        * @param string $direction
+        * @throws UnexpectedValueException
+        */
+       public function __construct( $property, $direction = self::ASCENDING ) {
+               $this->property = $property;
+               $this->direction = strtoupper( $direction );
+
+               if( !in_array( $this->direction, [ self::ASCENDING, 
self::DESCENDING ] ) ) {
+                       throw new UnexpectedValueException(
+                               "'{$this->direction}' is not an allowed value 
for argument \$direction"
+                       );
+               }
+       }
+
+       /**
+        *
+        * @return string
+        */
+       public function getProperty() {
+               return $this->property;
+       }
+
+       /**
+        *
+        * @return string One of Sort::ASCENDING or Sort::DESCENDING
+        */
+       public function getDirection() {
+               return $this->direction;
+       }
+
+       /**
+        *
+        * @return string
+        */
+       public function __toString() {
+               return $this->getProperty().' '.$this->getDirection();
+       }
+
+               /**
+        *
+        * @param stdClass[]|array[] $sorts
+        * @return Sort[]
+        */
+       public static function newCollectionFromArray( $sorts ) {
+               $sortObjects = [];
+               foreach( $sorts as $sort ) {
+                       if( is_array( $sort ) ) {
+                               $sort = (object) $sort;
+                       }
+
+                       $sortObjects[] = new Sort(
+                               $sort->property,
+                               isset( $sort->direction ) ? $sort->direction : 
null
+                       );
+               }
+               return $sortObjects;
+       }
+}
diff --git a/src/Data/Sorter.php b/src/Data/Sorter.php
new file mode 100644
index 0000000..b2fec4c
--- /dev/null
+++ b/src/Data/Sorter.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace BlueSpice\Data;
+
+class Sorter {
+
+       /**
+        *
+        * @var Sort[];
+        */
+       protected $sorts = null;
+
+       /**
+        *
+        * @param Sort[] $sorts
+        */
+       public function __construct( $sorts ) {
+               $this->sorts = $sorts;
+       }
+
+       /**
+        *
+        * @param \BlueSpice\Data\Record[] $dataSets
+        * @param array $unsortableProps
+        * @return \BlueSpice\Data\Record[]
+        */
+       public function sort( $dataSets, $unsortableProps = [] ) {
+               $numberOfSorts = count( $this->sorts );
+               $sortParams = [];
+               foreach ( $this->sorts as $sort ) {
+                       $property = $sort->getProperty();
+                       if( in_array( $property, $unsortableProps ) ) {
+                               continue;
+                       }
+
+                       $valuesOf{$property} = array();
+                       foreach( $dataSets as $idx => $dataSet ) {
+                               $valuesOf{$property}[$idx] =
+                                       $this->getSortValue( $dataSet, 
$property );
+                       }
+
+                       $sortParams[] = $valuesOf{$property};
+                       $sortParams[] = $this->getSortDirection( $sort );
+                       $sortParams[] = $this->getSortFlags( $property );
+               }
+
+               if( !empty( $sortParams ) ) {
+                       $sortParams[] = &$dataSets;
+                       call_user_func_array( 'array_multisort', $sortParams );
+               }
+
+               $dataSets = array_values( $dataSets );
+               return $dataSets;
+       }
+
+       /**
+        * Returns the flags for PHP 'array_multisort' function
+        * May be overridden by subclasses to provide different sort flags
+        * depending on the property
+        * @param string $property
+        * @return int see http://php.net/manual/en/array.constants.php for 
details
+        */
+       protected function getSortFlags( $property ) {
+               return SORT_NATURAL;
+       }
+
+       /**
+        * Returns the value a for a field a dataset is being sorted by.
+        * May be overridden by subclass to allow custom sorting
+        * @param \BlueSpice\Data\Record $dataSet
+        * @param string $property
+        * @return string
+        */
+       protected function getSortValue( $dataSet, $property ) {
+               $value = $dataSet->get( $property );
+               if( is_array( $value ) ) {
+                       return $this->getSortValueFromList( $value, $dataSet, 
$property );
+               }
+
+               return $value;
+       }
+
+       /**
+        * Normalizes an array to a string value that can be used in sort logic.
+        * May be overridden by subclass to customize sorting.
+        * Assumes that array entries can be casted to string.
+        * @param array $values
+        * @param \BlueSpice\Data\Record $dataSet
+        * @param string $property
+        * @return string
+        */
+       protected function getSortValueFromList( $values, $dataSet, $property ) 
{
+               $combinedValue = '';
+               foreach( $values as $value ) {
+                       // PHP 7 workaround. In PHP 7 cast throws no exception. 
It's a fatal error so no way to catch
+                       if( $this->canBeCastedToString( $value ) )
+                       {
+                               $combinedValue .= (string)$value;
+                       } else {
+                               $combinedValue .= FormatJson::encode( $value );
+                       }
+               }
+               return $combinedValue;
+       }
+
+
+       /**
+        * Checks if a array or object ist castable to string.
+        *
+        * @param mixed $value
+        * @return bool
+        */
+       protected function canBeCastedToString( $value ) {
+               if ( !is_array( $value ) &&
+                       ( !is_object( $value ) && settype( $value, 'string' ) 
!== false ) ||
+                       ( is_object( $value ) && method_exists( $value, 
'__toString' ) ) ) {
+                       return true;
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        *
+        * @param Sort $sort
+        * @return int Constant value of SORT_ASC or SORT_DESC
+        */
+       protected function getSortDirection( $sort ) {
+               if( $sort->getDirection() === Sort::ASCENDING ) {
+                       return SORT_ASC;
+               }
+               return  SORT_DESC;
+       }
+
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/PrimaryDataProvider.php 
b/src/Data/Watchlist/PrimaryDataProvider.php
new file mode 100644
index 0000000..1de86d5
--- /dev/null
+++ b/src/Data/Watchlist/PrimaryDataProvider.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+use \BlueSpice\Data\IPrimaryDataProvider;
+use \BlueSpice\Data\Filter;
+use \BlueSpice\Data\FilterFinder;
+
+class PrimaryDataProvider implements IPrimaryDataProvider {
+
+       /**
+        *
+        * @var \BlueSpice\Data\Record
+        */
+       protected $data = [];
+
+       /**
+        *
+        * @var int[]
+        */
+       protected $userIds = [];
+
+       /**
+        *
+        * @var int[]
+        */
+       protected $namespaceIds = [];
+
+       /**
+        *
+        * @var \Wikimedia\Rdbms\IDatabase
+        */
+       protected $db = null;
+
+       /**
+        *
+        * @param \Wikimedia\Rdbms\IDatabase $db
+        */
+       public function __construct( $db ) {
+               $this->db = $db;
+       }
+
+       /**
+        *
+        * @param string $query
+        * @param type $preFilters
+        */
+       public function makeData( $query = '', $preFilters = [] ) {
+               $res = $this->db->select(
+                       'watchlist',
+                       '*',
+                       $this->makePreFilterConds( $preFilters )
+               );
+
+               $distinctUserIds = [];
+               $distinctNamespaceIds = [];
+               foreach( $res as $row ) {
+                       $distinctUserIds[$row->wl_user] = true;
+                       $distinctNamespaceIds[$row->wl_namespace] = true;
+                       $this->appendRowToData( $row );
+               }
+
+               $this->userIds = array_keys( $distinctUserIds );
+               $this->addUserFields();
+
+               $this->namespaceIds = array_keys( $distinctNamespaceIds );
+               $this->addTitleFields();
+
+               return $this->data;
+       }
+
+       /**
+        *
+        * @param Filter[] $preFilters
+        * @return array
+        */
+       protected function makePreFilterConds( $preFilters ) {
+               $conds = [];
+               $filterFinder = new FilterFinder( $preFilters );
+               $userIdFilter = $filterFinder->findByField( 'user_id' );
+
+               if( $userIdFilter instanceof Filter ) {
+                       $conds['wl_user'] = $userIdFilter->getValue();
+               }
+
+               return $conds;
+       }
+
+       protected function appendRowToData( $row ) {
+               $title = \Title::makeTitle( $row->wl_namespace, $row->wl_title 
);
+
+               $this->data[] = new Record( (object) [
+                       Record::USER_ID => $row->wl_user,
+                       Record::USER_DISPLAY_NAME => '',
+                       Record::USER_LINK => '',
+                       Record::PAGE_ID => '',
+                       Record::PAGE_PREFIXED_TEXT => 
$title->getPrefixedText(), //Not expensive, as all required information 
available on instantiation
+                       Record::PAGE_LINK => '-',
+                       Record::NOTIFICATIONTIMESTAMP => 
$row->wl_notificationtimestamp,
+                       Record::HAS_UNREAD_CHANGES => 
$row->wl_notificationtimestamp !== null,
+                       Record::IS_TALK_PAGE => $title->isTalkPage()
+               ] );
+       }
+
+       protected function addUserFields() {
+               $res = $this->db->select(
+                       'user',
+                       [ 'user_id', 'user_name', 'user_real_name' ],
+                       [ 'user_id' => $this->userIds ]
+               );
+
+               $userDisplayNames = [];
+               foreach( $res as $row ) {
+                       $userDisplayNames[ $row->user_id ] = 
$row->user_real_name != null
+                               ? $row->user_real_name
+                               : $row->user_name;
+               }
+
+               foreach( $this->data as &$dataSet ) {
+                       $userId = $dataSet->get( Record::USER_ID );
+                       $dataSet->set( Record::USER_DISPLAY_NAME, 
$userDisplayNames[$userId] );
+               }
+       }
+
+       protected function addTitleFields() {
+               $res = $this->db->select(
+                       'page',
+                       [ 'page_id', 'page_title', 'page_namespace' ],
+                       [ 'page_namespace' => $this->namespaceIds ] //TODO 
maybe also add a collection of "page_title"s to narrow result
+               );
+
+               $pageIds = [];
+               foreach( $res as $row ) {
+                       $title = \Title::makeTitle( $row->page_namespace, 
$row->page_title );
+                       $pageIds[$title->getPrefixedText()] = $row->page_id;
+               }
+
+               foreach( $this->data as &$dataSet ) {
+                       $pagePrefixedText = $dataSet->get( 
Record::PAGE_PREFIXED_TEXT );
+                       $pageId = 0;
+
+                       //It is possible to watch non existing pages
+                       if( isset( $pageIds[$pagePrefixedText] ) ) {
+                               $pageId = $pageIds[$pagePrefixedText];
+                       }
+                       $dataSet->set( Record::PAGE_ID, $pageId );
+               }
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/Reader.php b/src/Data/Watchlist/Reader.php
new file mode 100644
index 0000000..68ab2da
--- /dev/null
+++ b/src/Data/Watchlist/Reader.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+use \BlueSpice\Data\DatabaseReader;
+
+class Reader extends DatabaseReader {
+
+       protected function makePrimaryDataProvider( $params ) {
+               return new PrimaryDataProvider( $this->db );
+       }
+
+       protected function makeSecondaryDataProvider() {
+               return new SecondaryDataProvider(
+                       
\MediaWiki\MediaWikiServices::getInstance()->getLinkRenderer()
+               );
+       }
+
+       public function getSchema() {
+               return new Schema();
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/Record.php b/src/Data/Watchlist/Record.php
new file mode 100644
index 0000000..b7f9c5c
--- /dev/null
+++ b/src/Data/Watchlist/Record.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+class Record extends \BlueSpice\Data\Record {
+       const USER_ID = 'user_id';
+       const USER_DISPLAY_NAME = 'user_display_name';
+       const USER_LINK = 'user_link';
+       const PAGE_ID = 'page_id';
+       const PAGE_PREFIXED_TEXT = 'page_prefixedtext';
+       const PAGE_LINK = 'page_link';
+       const NOTIFICATIONTIMESTAMP = 'notificationtimestamp';
+       const HAS_UNREAD_CHANGES = 'has_unread_changes';
+       const IS_TALK_PAGE = 'is_talk_page';
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/Schema.php b/src/Data/Watchlist/Schema.php
new file mode 100644
index 0000000..e83951f
--- /dev/null
+++ b/src/Data/Watchlist/Schema.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+use BlueSpice\Data\FieldType;
+
+class Schema extends \BlueSpice\Data\Schema {
+       public function __construct() {
+               parent::__construct( [
+                       Record::USER_ID => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::INT
+                       ],
+                       Record::USER_DISPLAY_NAME => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::STRING
+                       ],
+                       /**
+                        * Creating a link is expensive and the result is not 
filterable by
+                        * standard filters. Still they are important as hooks 
may modify
+                        * their content (e.g. by providing data attributes or 
other) and
+                        * they can contain additional information (e.g. 
redlink).
+                        * Therefore links always get created _after_ filtering 
and paging!
+                        */
+                       Record::USER_LINK => [
+                               self::FILTERABLE => false,
+                               self::SORTABLE => false,
+                               self::TYPE => FieldType::STRING
+                       ],
+                       Record::PAGE_ID => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::INT
+                       ],
+                       Record::PAGE_PREFIXED_TEXT => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::STRING
+                       ],
+                       Record::PAGE_LINK => [
+                               self::FILTERABLE => false,
+                               self::SORTABLE => false,
+                               self::TYPE => FieldType::STRING
+                       ],
+                       Record::NOTIFICATIONTIMESTAMP => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::DATE
+                       ],
+                       Record::HAS_UNREAD_CHANGES => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::BOOLEAN
+                       ],
+                       Record::IS_TALK_PAGE => [
+                               self::FILTERABLE => true,
+                               self::SORTABLE => true,
+                               self::TYPE => FieldType::BOOLEAN
+                       ],
+               ]);
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/SecondaryDataProvider.php 
b/src/Data/Watchlist/SecondaryDataProvider.php
new file mode 100644
index 0000000..0fc91a5
--- /dev/null
+++ b/src/Data/Watchlist/SecondaryDataProvider.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+class SecondaryDataProvider extends \BlueSpice\Data\SecondaryDataProvider {
+
+       /**
+        *
+        * @var \MediaWiki\Linker\LinkRenderer
+        */
+       protected $linkrenderer = null;
+
+       /**
+        *
+        * @param \MediaWiki\Linker\LinkRenderer $linkrenderer
+        */
+       public function __construct( $linkrenderer ) {
+               $this->linkrenderer = $linkrenderer;
+       }
+
+       protected function doExtend( &$dataSet ){
+               $user = \User::newFromId( $dataSet->get( Record::USER_ID ) );
+               $dataSet->set(
+                       Record::USER_LINK,
+                       $this->linkrenderer->makeLink( $user->getUserPage() )
+               );
+
+               $title = \Title::newFromText( $dataSet->get( 
Record::PAGE_PREFIXED_TEXT ) );
+               $dataSet->set(
+                       Record::PAGE_LINK,
+                       $this->linkrenderer->makeLink( $title )
+               );
+       }
+}
\ No newline at end of file
diff --git a/src/Data/Watchlist/Store.php b/src/Data/Watchlist/Store.php
new file mode 100644
index 0000000..bb72191
--- /dev/null
+++ b/src/Data/Watchlist/Store.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace BlueSpice\Data\Watchlist;
+
+class Store implements \BlueSpice\Data\IStore {
+
+       /**
+        *
+        * @var \IContextSource
+        */
+       protected $context = null;
+
+       /**
+        *
+        * @param \IContextSource $context
+        */
+       public function __construct( $context ) {
+               $this->context = $context;
+       }
+
+       public function getReader() {
+               return new Reader( 
\MediaWiki\MediaWikiServices::getInstance()->getDBLoadBalancer(), 
$this->context );
+       }
+
+       public function getWriter() {
+               throw new Exception( 'This store does not support writing!' );
+       }
+}
\ No newline at end of file
diff --git a/src/StoreApiBase.php b/src/StoreApiBase.php
new file mode 100644
index 0000000..04ac94c
--- /dev/null
+++ b/src/StoreApiBase.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace BlueSpice;
+
+/**
+ * Example request parameters of an ExtJS store
+
+       _dc:1430126252980
+       filter:[
+               {
+                       "type":"string",
+                       "comparison":"ct",
+                       "value":"some text ...",
+                       "field":"someField"
+               }
+       ]
+       group:[
+               {
+                       "property":"someOtherField",
+                       "direction":"ASC"
+               }
+       ]
+       sort:[
+               {
+                       "property":"someOtherField",
+                       "direction":"ASC"
+               }
+       ]
+       page:1
+       start:0
+       limit:25
+ */
+abstract class StoreApiBase extends \BSApiBase {
+
+       /**
+        * May be overwritten by subclass
+        * @var string
+        */
+       protected $root = 'results';
+
+       /**
+        * May be overwritten by subclass
+        * @var string
+        */
+       protected $totalProperty = 'total';
+
+       /**
+        * May be overwritten by subclass
+        * @var string
+        */
+       protected $metaData = 'metadata';
+
+       /**
+        * Main method called by \ApiMain
+        */
+       public function execute() {
+               $dataStore = $this->makeDataStore();
+               $result = $dataStore->getReader()->read( 
$this->getReaderParams() );
+               $schema = $dataStore->getReader()->getSchema();
+               $this->returnData( $result, $schema );
+       }
+
+       /**
+        * Creates a proper output format based on the class's properties
+        * @param \BlueSpice\Data\ResultSet $resultSet Holds the records as an
+        * array of plain old data objects
+        * @param \BlueSpice\Data\Schema $schema An array of meta data items
+        */
+       protected function returnData( $resultSet, $schema = null ) {
+               \Hooks::run( 'BSApiStoreBaseBeforeReturnData', array( $this, 
&$resultSet, &$schema ) );
+               $apiResult = $this->getResult();
+
+               //Unfortunately \ApiResult does not like \JsonSerializable[], 
so we
+               //need to provide a \stdClass[] or array[]
+               $converter = new Data\RecordConverter( $resultSet->getRecords() 
);
+               $records = $converter->convertToRawData();
+
+               $apiResult->setIndexedTagName( $records, $this->root );
+               $apiResult->addValue( null, $this->root, $records  );
+               $apiResult->addValue( null, $this->totalProperty, 
$resultSet->getTotal() );
+               if( $schema !== null ) {
+                       $apiResult->addValue( null, $this->metaData, $schema );
+               }
+       }
+
+       /**
+        * Called by ApiMain
+        * @return array
+        */
+       public function getAllowedParams() {
+               return array(
+                       'sort' => array(
+                               \ApiBase::PARAM_TYPE => 'string',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => '[]',
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-sort',
+                       ),
+                       'group' => array(
+                               \ApiBase::PARAM_TYPE => 'string',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => '[]',
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-group',
+                       ),
+                       'filter' => array(
+                               \ApiBase::PARAM_TYPE => 'string',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => '[]',
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-filter',
+                       ),
+                       'page' => array(
+                               \ApiBase::PARAM_TYPE => 'integer',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => 0,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-page',
+                       ),
+                       'limit' => array(
+                               \ApiBase::PARAM_TYPE => 'integer',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => 25,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-limit',
+                       ),
+                       'start' => array(
+                               \ApiBase::PARAM_TYPE => 'integer',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_DFLT => 0,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-start',
+                       ),
+                       'callback' => array(
+                               \ApiBase::PARAM_TYPE => 'string',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-callback',
+                       ),
+                       'query' => array(
+                               \ApiBase::PARAM_TYPE => 'string',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-query',
+                       ),
+                       '_dc' => array(
+                               \ApiBase::PARAM_TYPE => 'integer',
+                               \ApiBase::PARAM_REQUIRED => false,
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-dc',
+                       ),
+                       'format' => array(
+                               \ApiBase::PARAM_DFLT => 'json',
+                               \ApiBase::PARAM_TYPE => array( 'json', 'jsonfm' 
),
+                               \ApiBase::PARAM_HELP_MSG => 
'apihelp-bs-store-param-format',
+                       )
+               );
+       }
+
+       public function getParamDescription() {
+               return array(
+                       'sort' => 'JSON string with sorting info; deserializes 
to "array of objects" that hold field name and direction for each sorting 
option',
+                       'group' => 'JSON string with grouping info; 
deserializes to "array of objects" that hold field name and direction for each 
grouping option',
+                       'filter' => 'JSON string with filter info; deserializes 
to "array of objects" that hold field name, filter type, and filter value for 
each filtering option',
+                       'page' => 'Allows server side calculation of 
start/limit',
+                       'limit' => 'Number of results to return',
+                       'start' => 'The offset to start the result list from',
+                       'query' => 'Similar to "filter", but the provided value 
serves as a filter only for the "value" field of an ExtJS component',
+                       'callback' => 'A method name in the client code that 
should be called in the response (JSONP)',
+                       '_dc' => '"Disable cache" flag',
+                       'format' => 'The format of the output (only JSON or 
formatted JSON)'
+               );
+       }
+
+       protected function getParameterFromSettings( $paramName, 
$paramSettings, $parseLimit ) {
+               $value = parent::getParameterFromSettings( $paramName, 
$paramSettings, $parseLimit );
+               //Unfortunately there is no way to register custom types for 
parameters
+               if( in_array( $paramName, [ 'sort', 'group', 'filter' ] ) ) {
+                       $value = \FormatJson::decode( $value );
+                       if( empty( $value ) ) {
+                               return [];
+                       }
+               }
+               return $value;
+       }
+
+       public function getParameter( $paramName, $parseLimit = true ) {
+               //Make this public, so hook handler could get the params
+               return parent::getParameter( $paramName, $parseLimit );
+       }
+
+       /**
+        * @return \BlueSpice\Data\IStore
+        */
+       protected abstract function makeDataStore();
+
+       /**
+        *
+        * @return \BlueSpice\Data\ReaderParams
+        */
+       protected function getReaderParams() {
+               return new \BlueSpice\Data\ReaderParams([
+                       'query' => $this->getParameter( 'query', null ),
+                       'start' => $this->getParameter( 'start', null ),
+                       'limit' => $this->getParameter( 'limit', null ),
+                       'filter' => $this->getParameter( 'filter', null ),
+                       'sort' => $this->getParameter( 'sort', null ),
+               ]);
+       }
+
+}
\ No newline at end of file
diff --git a/tests/phpunit/Data/Filter/BooleanTest.php 
b/tests/phpunit/Data/Filter/BooleanTest.php
new file mode 100644
index 0000000..59f07e0
--- /dev/null
+++ b/tests/phpunit/Data/Filter/BooleanTest.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Filter;
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class BooleanTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new Filter\Boolean( [
+                       'field' => 'field1',
+                       'comparison' => 'eq',
+                       'value' => true
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => true,
+                       'field2' => false
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new Filter\Boolean( [
+                       'field' => 'field1',
+                       'comparison' => 'eq',
+                       'value' => false
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => true,
+                       'field2' => false
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+
+
+       /**
+        * @dataProvider provideAppliesToValues
+        * @param boolean $expecation
+        * @param mixed $fieldValue
+        * @param mixed $filterValue
+        * @param string $comparison
+        */
+       public function testAppliesTo ( $expectation, $comparison, $fieldValue, 
$filterValue ) {
+               $filter = new Filter\Boolean( [
+                       Filter\Boolean::KEY_FIELD => 'field_A',
+                       Filter\Boolean::KEY_VALUE => $filterValue,
+                       Filter\Boolean::KEY_COMPARISON => $comparison
+               ] );
+
+               $dataSet = new Record( (object) [
+                       'field_A' => $fieldValue
+               ] );
+
+               if( $expectation ) {
+                       $this->assertTrue( $filter->matches( $dataSet ), 
'Filter should apply' );
+               }
+               else {
+                       $this->assertFalse( $filter->matches( $dataSet ), 
'Filter should not apply' );
+               }
+       }
+
+       public function provideAppliesToValues() {
+               return [
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, true, true ],
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, 1, true ],
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, '1', true ],
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, false, false 
],
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, 0, false ],
+                       [ true, Filter\Boolean::COMPARISON_EQUALS, '0', false ],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, true, 
false ],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, 1, false 
],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, '1', 
false ],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, false, 
true ],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, 0, true 
],
+                       [ true, Filter\Boolean::COMPARISON_NOT_EQUALS, '0', 
true ],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, true, false 
],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, 1, false ],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, '1', false 
],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, false, true 
],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, 0, true ],
+                       [ false, Filter\Boolean::COMPARISON_EQUALS, '0', true ],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, true, 
true ],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, 1, true 
],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, '1', 
true ],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, false, 
false ],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, 0, 
false ],
+                       [ false, Filter\Boolean::COMPARISON_NOT_EQUALS, '0', 
false ]
+               ];
+       }
+}
diff --git a/tests/phpunit/Data/Filter/DateTest.php 
b/tests/phpunit/Data/Filter/DateTest.php
new file mode 100644
index 0000000..d54830a
--- /dev/null
+++ b/tests/phpunit/Data/Filter/DateTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use \BlueSpice\Data\Filter;
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class DateTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new Filter\Date( [
+                       'field' => 'field1',
+                       'comparison' => 'gt',
+                       'value' => '2017/01/01'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => '20170101000001',
+                       'field2' => false
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\Date( [
+                       'field' => 'field1',
+                       'comparison' => 'gt',
+                       'value' => '2017/01/02'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => '20170101000001',
+                       'field2' => false
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * @dataProvider provideAppliesToValues
+        * @param boolean $expecation
+        * @param mixed $fieldValue
+        * @param mixed $filterValue
+        * @param string $comparison
+        */
+       public function testAppliesTo ( $expectation, $comparison, $fieldValue, 
$filterValue ) {
+               $filter = new Filter\Date([
+                       Filter::KEY_FIELD => 'field_A',
+                       Filter::KEY_VALUE => $filterValue,
+                       Filter::KEY_COMPARISON => $comparison
+               ]);
+
+               $dataSet = new Record( (object)[
+                       'field_A' => $fieldValue
+               ] );
+
+               if( $expectation ) {
+                       $this->assertTrue( $filter->matches( $dataSet ), 
'Filter should apply' );
+               }
+               else {
+                       $this->assertFalse( $filter->matches( $dataSet ), 
'Filter should not apply' );
+               }
+       }
+
+       public function provideAppliesToValues() {
+               return [
+                       [ true, Filter::COMPARISON_EQUALS, '2017-07-01', 
'20170701000000' ],
+                       [ true, Filter::COMPARISON_EQUALS, 0, '1970-01-01' ],
+                       [ true, Filter::COMPARISON_EQUALS, '1970/01/02', 
'1970-01-02' ],
+                       [ true, Filter\Range::COMPARISON_GREATER_THAN, 
'1970/01/02', 1 ],
+                       [ true, Filter\Range::COMPARISON_LOWER_THAN, 
'1970/01/02', 'now' ],
+                       [ true, Filter\Range::COMPARISON_LOWER_THAN, 'now - 1 
week', 'now' ],
+                       [ false, Filter\Range::COMPARISON_EQUALS, 'now - 1 
week', 'now' ],
+               ];
+       }
+}
diff --git a/tests/phpunit/Data/Filter/ListValueTest.php 
b/tests/phpunit/Data/Filter/ListValueTest.php
new file mode 100644
index 0000000..feacbfc
--- /dev/null
+++ b/tests/phpunit/Data/Filter/ListValueTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class ListValueTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new \BlueSpice\Data\Filter\ListValue( [
+                       'field' => 'field1',
+                       'comparison' => 'ct',
+                       'value' => [ 'Hello' ]
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => [ 'Hello', 'World' ],
+                       'field2' => false
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\ListValue( [
+                       'field' => 'field1',
+                       'comparison' => 'ct',
+                       'value' => [ 'Hello' ]
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => [ 'Hallo', 'Welt' ],
+                       'field2' => false
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+}
diff --git a/tests/phpunit/Data/Filter/NumericTest.php 
b/tests/phpunit/Data/Filter/NumericTest.php
new file mode 100644
index 0000000..d9d137d
--- /dev/null
+++ b/tests/phpunit/Data/Filter/NumericTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class NumericTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new \BlueSpice\Data\Filter\Numeric( [
+                       'field' => 'field1',
+                       'comparison' => 'gt',
+                       'value' => 5
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 7,
+                       'field2' => 3
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\Numeric( [
+                       'field' => 'field1',
+                       'comparison' => 'gt',
+                       'value' => 5
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 3,
+                       'field2' => 7
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+}
diff --git a/tests/phpunit/Data/Filter/StringValueTest.php 
b/tests/phpunit/Data/Filter/StringValueTest.php
new file mode 100644
index 0000000..4456358
--- /dev/null
+++ b/tests/phpunit/Data/Filter/StringValueTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class StringValueTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new \BlueSpice\Data\Filter\StringValue( [
+                       'field' => 'field1',
+                       'comparison' => 'ct',
+                       'value' => 'ello'
+               ] );
+
+               $result = $filter->matches( new Record( (object) [
+                       'field1' => 'Hello World',
+                       'field2' => 'Hallo Welt'
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\StringValue( [
+                       'field' => 'field1',
+                       'comparison' => 'ct',
+                       'value' => 'allo'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 'Hello World',
+                       'field2' => 'Hallo Welt'
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+}
diff --git a/tests/phpunit/Data/Filter/TemplateTitleTest.php 
b/tests/phpunit/Data/Filter/TemplateTitleTest.php
new file mode 100644
index 0000000..789fc28
--- /dev/null
+++ b/tests/phpunit/Data/Filter/TemplateTitleTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class TemplateTitleTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new \BlueSpice\Data\Filter\TemplateTitle( [
+                       'field' => 'field1',
+                       'comparison' => 'eq',
+                       'value' => 'Help'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 'Template:Help',
+                       'field2' => 'User:WikiSysop'
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\TemplateTitle( [
+                       'field' => 'field1',
+                       'comparison' => 'eq',
+                       'value' => 'Help'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 'Vorlage:Hilfe',
+                       'field2' => 'User:WikiSysop'
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+}
diff --git a/tests/phpunit/Data/Filter/TitleTest.php 
b/tests/phpunit/Data/Filter/TitleTest.php
new file mode 100644
index 0000000..412a703
--- /dev/null
+++ b/tests/phpunit/Data/Filter/TitleTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace BlueSpice\Tests\Data\Filter;
+
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class TitleTest extends \PHPUnit_Framework_TestCase {
+       public function testPositive() {
+               $filter = new \BlueSpice\Data\Filter\Title( [
+                       'field' => 'field2',
+                       'comparison' => 'eq',
+                       'value' => 'User:WikiSysop'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 'Template:Help',
+                       'field2' => 'User:WikiSysop'
+               ] ) );
+
+               $this->assertTrue( $result );
+       }
+
+       public function testNegative() {
+               $filter = new \BlueSpice\Data\Filter\Title( [
+                       'field' => 'field1',
+                       'comparison' => 'eq',
+                       'value' => 'Hilfe'
+               ] );
+
+               $result = $filter->matches( new Record( (object)[
+                       'field1' => 'Vorlage:Hilfe',
+                       'field2' => 'User:WikiSysop'
+               ] ) );
+
+               $this->assertFalse( $result );
+       }
+}
diff --git a/tests/phpunit/Data/FiltererTest.php 
b/tests/phpunit/Data/FiltererTest.php
new file mode 100644
index 0000000..f7bf7ad
--- /dev/null
+++ b/tests/phpunit/Data/FiltererTest.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace BlueSpice\Tests\Data;
+
+use BlueSpice\Data\Filterer;
+use BlueSpice\Data\Filter;
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class FiltererTest extends \PHPUnit_Framework_TestCase {
+       protected $testDataSets = [
+               [
+                       'field1' => 1,
+                       'field2' => '20170101000000',
+                       'field3' => '1 item',
+                       'field4' => [ 1, 2, 3 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 3,
+                       'field2' => '20170101000002',
+                       'field3' => '10 items',
+                       'field4' => [ 2, 3, 4 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 4,
+                       'field2' => '20170101000001',
+                       'field3' => 'an item',
+                       'field4' => [ 4, 5, 6 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 2,
+                       'field2' => '20170101000003',
+                       'field3' => 'An eloquent item',
+                       'field4' => [ 3, 1, 2 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ]
+       ];
+
+       /**
+        *
+        * @param array $filter
+        * @param array $expectedCount
+        * @dataProvider provideFilterData
+        */
+       public function testFilter( $filters, $expectedCount ) {
+               $filterer = new Filterer( Filter::newCollectionFromArray( 
$filters ) );
+               $dataSets = $this->makeDataSets();
+               $filteredDataSets = $filterer->filter( $dataSets );
+
+               $this->assertEquals( $expectedCount, count( $filteredDataSets ) 
);
+       }
+
+       public function provideFilterData() {
+               return [
+                       'numeric' => [
+                               [ [
+                                       'type' => 'numeric',
+                                       'field' => 'field1',
+                                       'value' => 2,
+                                       'comparison' => 'gt'
+                               ] ],
+                               2
+                       ],
+                       'list' => [
+                               [ [
+                                       'type' => 'list',
+                                       'field' => 'field4',
+                                       'value' => [ 2 ],
+                                       'comparison' => 'ct'
+                               ] ],
+                               3
+                       ],
+                       'string and datetime' => [
+                               [ [
+                                       'type' => 'string',
+                                       'field' => 'field3',
+                                       'value' => 'item',
+                                       'comparison' => 'ew'
+                               ],
+                               [
+                                       'type' => 'date',
+                                       'field' => 'field2',
+                                       'value' => '20170101000000',
+                                       'comparison' => 'gt'
+                               ] ],
+                               2
+                       ],
+                       'string' => [
+                               [ [
+                                       'type' => 'string',
+                                       'field' => 'field3',
+                                       'value' => 'an',
+                                       'comparison' => 'ct'
+                               ] ],
+                               2
+                       ],
+               ];
+       }
+
+       protected function makeDataSets() {
+               $dataSets = [];
+               foreach( $this->testDataSets as $dataSet ) {
+                       $dataSets[] = new Record( (object) $dataSet );
+               }
+
+               return $dataSets;
+       }
+
+}
diff --git a/tests/phpunit/Data/LimitOffsetTrimmerTest.php 
b/tests/phpunit/Data/LimitOffsetTrimmerTest.php
new file mode 100644
index 0000000..f0ba04f
--- /dev/null
+++ b/tests/phpunit/Data/LimitOffsetTrimmerTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace BlueSpice\Tests\Data;
+
+use BlueSpice\Data\LimitOffsetTrimmer;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class LimitOffsetTrimmerTest extends \PHPUnit_Framework_TestCase {
+
+       protected $testDataSets = [
+               //Page 1
+               'Zero',
+               'One',
+               'Two',
+               'Three',
+               'Four',
+
+               //Page 2
+               'Five',
+               'Six',
+               'Seven',
+               'Eight',
+               'Nine',
+
+               //Page 3
+               'Ten',
+               'Eleven',
+               'Twelve',
+               'Thirteen'
+       ];
+
+       public function testNormalPage() {
+               $trimmer = new LimitOffsetTrimmer( 5, 5 );
+               $trimmedData = $trimmer->trim( $this->testDataSets );
+
+               $this->assertEquals( count( $trimmedData ), 5 );
+               $this->assertEquals( $trimmedData[0], 'Five' );
+       }
+
+       public function testLastPage() {
+               $trimmer = new LimitOffsetTrimmer( 5, 10 );
+               $trimmedData = $trimmer->trim( $this->testDataSets );
+
+               $this->assertEquals( count( $trimmedData ), 4 );
+               $this->assertEquals( $trimmedData[0], 'Ten' );
+       }
+}
\ No newline at end of file
diff --git a/tests/phpunit/Data/ReaderParamsTest.php 
b/tests/phpunit/Data/ReaderParamsTest.php
new file mode 100644
index 0000000..2af1858
--- /dev/null
+++ b/tests/phpunit/Data/ReaderParamsTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace BlueSpice\Tests\Data;
+
+use \BlueSpice\Data\ReaderParams;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class ReaderParamsTest extends \PHPUnit_Framework_TestCase {
+       public function testInitFromArray() {
+               $params = new ReaderParams([
+                       'query' => 'Some query',
+                       'limit' => 50,
+                       'start' => 100,
+                       'sort' => [
+                               [ 'property' => 'prop_a', 'direction' => 'asc' 
],
+                               [ 'property' => 'prop_b', 'direction' => 'desc' 
]
+                       ],
+                       'filter' => [
+                               [ 
+                                       'type' => 'string',
+                                       'comparison' => 'ct',
+                                       'value' => 'test',
+                                       'field' => 'prop_a'
+                               ],
+                               [
+                                       'type' => 'numeric',
+                                       'comparison' => 'gt',
+                                       'value' => 99,
+                                       'field' => 'prop_b'
+                               ]
+                       ]
+               ]);
+
+               $this->assertInstanceOf( '\BlueSpice\Data\ReaderParams', 
$params );
+
+               //TODO: Split test
+               $this->assertEquals( 'Some query', $params->getQuery() );
+               $this->assertEquals( 100, $params->getStart() );
+               $this->assertEquals( 50, $params->getLimit() );
+
+               $sort = $params->getSort();
+               $this->assertEquals( 2, count( $sort ) );
+               $firstSort = $sort[0];
+               $this->assertInstanceOf(
+                       '\BlueSpice\Data\Sort',  $firstSort
+               );
+
+               $this->assertEquals(
+                       \BlueSpice\Data\Sort::ASCENDING,
+                       $firstSort->getDirection()
+               );
+
+               $filter = $params->getFilter();
+               $this->assertEquals( 2, count( $filter ) );
+
+               $firstFilter = $filter[0];
+               $this->assertInstanceOf(
+                       '\BlueSpice\Data\Filter',  $firstFilter
+               );
+
+               $this->assertEquals(
+                       \BlueSpice\Data\Filter\StringValue::COMPARISON_CONTAINS,
+                       $firstFilter->getComparison()
+               );
+
+               $filedNames = [];
+               foreach( $filter as $filterObject ) {
+                       $filedNames[] = $filterObject->getField();
+               }
+
+               $this->assertTrue( in_array( 'prop_a', $filedNames ) );
+       }
+}
\ No newline at end of file
diff --git a/tests/phpunit/Data/SorterTest.php 
b/tests/phpunit/Data/SorterTest.php
new file mode 100644
index 0000000..59c0868
--- /dev/null
+++ b/tests/phpunit/Data/SorterTest.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace BlueSpice\Tests\Data;
+
+use BlueSpice\Data\Sorter;
+use BlueSpice\Data\Sort;
+use BlueSpice\Data\Record;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ */
+class SorterTest extends \PHPUnit_Framework_TestCase {
+       protected $testDataSets = [
+               [
+                       'field1' => 1,
+                       'field2' => '20170101000000',
+                       'field3' => '1 item',
+                       'field4' => [ 1, 2, 3 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 3,
+                       'field2' => '20170101000002',
+                       'field3' => '10 items',
+                       'field4' => [ 2, 3, 4 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 4,
+                       'field2' => '20170101000001',
+                       'field3' => 'an item',
+                       'field4' => [ 4, 5, 6 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+               [
+                       'field1' => 2,
+                       'field2' => '20170101000003',
+                       'field3' => 'An eloquent item',
+                       'field4' => [ 3, 1, 2 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+
+               //Identically to filed1=4 but with different timestamp to allow 
testing
+               //of multible sorters
+               [
+                       'field1' => 5,
+                       'field2' => '20170101000002',
+                       'field3' => 'an item',
+                       'field4' => [ 4, 5, 6 ],
+                       'field5' => [
+                               [ 'a' => 1 ],
+                               [ 'a' => 2 ],
+                               [ 'a' => 3 ]
+                       ]
+               ],
+       ];
+
+       /**
+        *
+        * @param array $sort
+        * @param array $expectedSorting
+        * @dataProvider provideSortData
+        */
+       public function testSort( $sort, $expectedSorting ) {
+               $sorter = new Sorter( Sort::newCollectionFromArray( $sort ) );
+               $dataSets = $this->makeDataSets();
+               $sortedDataSets = $sorter->sort( $dataSets );
+
+               foreach( $sortedDataSets as $index => $dataSet ) {
+                       $this->assertEquals( $expectedSorting[$index], 
$dataSet->get( 'field1' ) );
+               }
+       }
+
+       public function provideSortData() {
+               return [
+                       'numeric-asc' => [
+                               [ [ 'property' => 'field1', 'direction' => 
'ASC' ] ],
+                               [ 1, 2, 3, 4, 5 ]
+                       ],
+                       'numeric-desc' => [
+                               [ [ 'property' => 'field1', 'direction' => 
'DESC' ] ],
+                               [ 5, 4, 3, 2, 1 ]
+                       ],
+                       'string-asc' => [
+                               [ [ 'property' => 'field3', 'direction' => 
'ASC' ] ],
+                               [ 1, 3, 2, 4, 5 ]
+                       ],
+                       'datetime-asc' => [
+                               [ [ 'property' => 'field2', 'direction' => 
'ASC' ] ],
+                               [ 1, 4, 3, 2, 5 ]
+                       ],
+                       'datetime-asc' => [
+                               [ [ 'property' => 'field2', 'direction' => 
'ASC' ] ],
+                               [ 1, 4, 3, 5, 2 ]
+                       ]
+               ];
+       }
+
+       protected function makeDataSets() {
+               $dataSets = [];
+               foreach( $this->testDataSets as $dataSet ) {
+                       $dataSets[] = new Record( (object)$dataSet );
+               }
+
+               return $dataSets;
+       }
+
+}
diff --git a/tests/phpunit/Data/Watchlist/ReaderTest.php 
b/tests/phpunit/Data/Watchlist/ReaderTest.php
new file mode 100644
index 0000000..222d112
--- /dev/null
+++ b/tests/phpunit/Data/Watchlist/ReaderTest.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace BlueSpice\Tests\DataSources;
+
+/**
+ * @group BlueSpice
+ * @group BlueSpiceFoundation
+ * @group Database
+ * @group heavy
+ */
+class WatchlistTest extends \MediaWikiTestCase {
+
+       protected $tablesUsed = [ 'watchlist', 'user' ];
+
+       public function addDBData() {
+
+               $this->insertPage( 'Test A' );
+               $this->insertPage( 'Talk:Test A' );
+               $this->insertPage( 'Test B' );
+               $this->insertPage( 'Talk:Test B' );
+               $this->insertPage( 'Test C' );
+               $this->insertPage( 'Test D' );
+
+               $dummyDbEntries = [
+                       [ 1, 0, 'Test A', null ],
+                       [ 1, 1, 'Test A', null ],
+                       [ 1, 0, 'Test B', null ],
+                       [ 1, 1, 'Test B', '19700101000000' ],
+                       [ 1, 0, 'Test C', null ],
+                       [ 1, 0, 'Test D', null ],
+                       [ 2, 0, 'Test A', null ],
+                       [ 2, 1, 'Test A', null ],
+                       [ 2, 0, 'Test B', null ]
+               ];
+
+               $dbw = wfGetDB( DB_MASTER );
+               foreach( $dummyDbEntries as $dummyDbEntry ) {
+                       $dbw->insert( 'watchlist', [
+                               'wl_user' => $dummyDbEntry[0],
+                               'wl_namespace' => $dummyDbEntry[1],
+                               'wl_title' => $dummyDbEntry[2],
+                               'wl_notificationtimestamp' => $dummyDbEntry[3],
+                       ] );
+               }
+
+               $user = \User::newFromName( 'UTWatchlist' );
+               if ( $user->getId() == 0 ) {
+                       $user->addToDatabase();
+                       \TestUser::setPasswordForUser( $user, 'UTWatchlist' );
+
+                       $user->saveSettings();
+               }
+       }
+
+       public function testCanConstruct() {
+               $loadBalancer = $this->getMockBuilder( 
'\Wikimedia\Rdbms\LoadBalancer' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $this->assertInstanceOf(
+                       '\BlueSpice\Data\Watchlist\Reader',
+                       new \BlueSpice\Data\Watchlist\Reader(
+                               $loadBalancer
+                       )
+               );
+       }
+
+       public function testUnfilteredFetching() {
+               $watchlist = $this->makeInstance();
+               $resultSet = $watchlist->read( new 
\BlueSpice\Data\ReaderParams() );
+
+               $records = $resultSet->getRecords();
+               $total = $resultSet->getTotal();
+
+               $this->assertEquals( 9 , count( $records ), 'Count of datasets 
in result is wrong' );
+               $this->assertEquals( 9 , $total, 'Count of total datasets is 
wrong' );
+       }
+
+       /**
+        *
+        * @return \BlueSpice\Data\Watchlist\Reader
+        */
+       protected function makeInstance() {
+               return new \BlueSpice\Data\Watchlist\Reader(
+                       
\MediaWiki\MediaWikiServices::getInstance()->getDBLoadBalancer()
+               );
+       }
+}
\ No newline at end of file

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

Gerrit-MessageType: merged
Gerrit-Change-Id: Ie4ee268a2c42c9066a4c8667f01dce0b2db6799c
Gerrit-PatchSet: 12
Gerrit-Project: mediawiki/extensions/BlueSpiceFoundation
Gerrit-Branch: master
Gerrit-Owner: Robert Vogel <[email protected]>
Gerrit-Reviewer: Ljonka <[email protected]>
Gerrit-Reviewer: Mglaser <[email protected]>
Gerrit-Reviewer: Pwirth <[email protected]>
Gerrit-Reviewer: Raimond Spekking <[email protected]>
Gerrit-Reviewer: Robert Vogel <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

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

Reply via email to