From 14c02d56ac36f1aaea8a7b2835da7fab112af12c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 16:26:38 -0500 Subject: [PATCH] Implement new context options other than not(). Context handling has also been re-organized to simplify later implementation of the not() option --- lib/Database.php | 195 +++++++++++-------------- tests/cases/Database/Base.php | 4 +- tests/cases/Database/SeriesArticle.php | 166 ++++++++++----------- 3 files changed, 163 insertions(+), 202 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 0310c5a..353e7b9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,8 +39,6 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; - /** The maximum number of search terms allowed; this is a hard limit */ - const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -129,7 +127,7 @@ class Database { /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value * - * Returns an indexed array containing the clause text, and an array of types + * Returns an indexed array containing the clause text, an array of types, and the array of values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value @@ -138,6 +136,7 @@ class Database { $out = [ "", // query clause [], // binding types + $values, // binding values ]; if (sizeof($values)) { // the query clause is just a series of question marks separated by commas @@ -1096,8 +1095,32 @@ class Database { * @param array $cols The columns to request in the result set */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + // dereference the label name to an ID + $context->label((int) $this->labelValidateId($user, $context->labelName, true)['id']); + $context->labelName(null); + } + // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); - // prepare the output column list $colDefs = [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", @@ -1107,6 +1130,7 @@ class Database { 'content' => "arsse_articles.content", 'guid' => "arsse_articles.guid", 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", 'subscription' => "arsse_subscriptions.id", 'feed' => "arsse_subscriptions.feed", 'starred' => "coalesce(arsse_marks.starred,0)", @@ -1148,127 +1172,82 @@ class Database { ["str"], [$user] ); + $q->setLimit($context->limit, $context->offset); $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); if ($cols) { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } - $q->setLimit($context->limit, $context->offset); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $this->subscriptionValidateId($user, $context->subscription); - // filter for the subscription - $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); - } elseif ($context->folder()) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $context->folder); - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); - // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); - } elseif ($context->folderShallow()) { - // if a shallow folder is specified, make sure it exists - $this->folderValidateId($user, $context->folderShallow); - // if it does exist, filter for that folder only - $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); - } - if ($context->edition()) { - // if an edition is specified, first validate it, then filter for it - $this->articleValidateEdition($user, $context->edition); - $q->setWhere("latest_editions.edition = ?", "int", $context->edition); - } elseif ($context->article()) { - // if an article is specified, first validate it, then filter for it - $this->articleValidateId($user, $context->article); - $q->setWhere("arsse_articles.id = ?", "int", $context->article); - } - if ($context->editions()) { - // if multiple specific editions have been requested, filter against the list - if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore - } - list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); - } elseif ($context->articles()) { - // if multiple specific articles have been requested, filter against the list - if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + // handle the simple context options + foreach ([ + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", 1], + "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", 1], + "articles" => ["id", "in", "int", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", 1], + "latestArticle" => ["id", "<=", "int", 1], + "oldestEdition" => ["edition", ">=", "int", 1], + "latestEdition" => ["edition", "<=", "int", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", 1], + "markedSince" => ["marked_date", ">=", "datetime", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", 1], + "folderShallow" => ["folder", "=", "int", 1], + "subscription" => ["subscription", "=", "int", 1], + "unread" => ["unread", "=", "bool", 1], + "starred" => ["starred", "=", "bool", 1], + ] as $m => list($col, $op, $type, $max)) { + if (!$context->$m()) { + // context is not being used + continue; + } elseif (is_array($context->$m)) { + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } else { + $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); } - // filter based on label by ID or name + // handle complex context options if ($context->labelled()) { // any label (true) or no label (false) $isOrIsNot = (!$context->labelled ? "is" : "is not"); $q->setWhere("arsse_labels.id $isOrIsNot null"); - } elseif ($context->label() || $context->labelName()) { - // specific label ID or name - if ($context->label()) { - $id = $this->labelValidateId($user, $context->label, false)['id']; - } else { - $id = $this->labelValidateId($user, $context->labelName, true)['id']; - } - $q->setWhere("arsse_labels.id = ?", "int", $id); - } - // filter based on article or edition offset - if ($context->oldestArticle()) { - $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); - } - if ($context->latestArticle()) { - $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); - } - if ($context->oldestEdition()) { - $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); } - if ($context->latestEdition()) { - $q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition); + if ($context->label()) { + // label ID (label names are dereferenced during input validation above) + $q->setWhere("arsse_labels.id = ?", "int", $context->label); } - // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) - if ($context->modifiedSince()) { - $q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); - } - if ($context->notModifiedSince()) { - $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); - } - if ($context->markedSince()) { - $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); - } - if ($context->notMarkedSince()) { - $q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince); - } - // filter for un/read and un/starred status if specified - if ($context->unread()) { - $q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); - } - if ($context->starred()) { - $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); - } - // filter based on whether the article has a note if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } - // filter based on search terms - if ($context->searchTerms()) { - if (!$context->searchTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); - } - $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); - } - // filter based on search terms in note - if ($context->annotationTerms()) { - if (!$context->annotationTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "annotationTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->annotationTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "annotationTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); + if ($context->folder()) { + // add a common table expression to list the folder and its children so that we select from the entire subtree + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + // limit subscriptions to the listed folders + $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); + } + // handle text-matching context options + foreach ([ + "titleTerms" => [10, ["arsse_articles.title"]], + "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], + "authorTerms" => [10, ["arsse_articles.author"]], + "annotationTerms" => [20, ["arsse_marks.note"]], + ] as $m => list($max, $cols)) { + if (!$context->$m()) { + continue; + } elseif (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); } - $q->setWhere(...$this->generateSearch($context->annotationTerms, ["arsse_marks.note"])); + $q->setWhere(...$this->generateSearch($context->$m, $cols)); } // return the query return $q; @@ -1306,7 +1285,7 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index b40056e..219d4c0 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -66,7 +66,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { // get the name of the test's test series - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); static::clearData(); static::setConf(); if (strlen(static::$failureReason)) { @@ -88,7 +88,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { // call the series-specific teardown method - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); $tearDown = "tearDown".$this->series; $this->$tearDown(); // clean up diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 80114c4..8530099 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -114,10 +114,10 @@ trait SeriesArticle { [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], - [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -414,94 +414,76 @@ trait SeriesArticle { $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } - public function testListArticlesCheckingContext() { - $compareIds = function(array $exp, Context $c) { - $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); - sort($ids); - sort($exp); - $this->assertEquals($exp, $ids); - }; - // get all items for user - $exp = [1,2,3,4,5,6,7,8,19,20]; - $compareIds($exp, new Context); - $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); - // get items from a folder tree - $compareIds([5,6,7,8], (new Context)->folder(1)); - // get items from a leaf folder - $compareIds([7,8], (new Context)->folder(6)); - // get items from a non-leaf folder without descending - $compareIds([1,2,3,4], (new Context)->folderShallow(0)); - $compareIds([5,6], (new Context)->folderShallow(1)); - // get items from a single subscription - $exp = [19,20]; - $compareIds($exp, (new Context)->subscription(5)); - // get un/read items from a single subscription - $compareIds([20], (new Context)->subscription(5)->unread(true)); - $compareIds([19], (new Context)->subscription(5)->unread(false)); - // get starred articles - $compareIds([1,20], (new Context)->starred(true)); - $compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); - $compareIds([1], (new Context)->starred(true)->unread(false)); - $compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); - // get items relative to edition - $compareIds([19], (new Context)->subscription(5)->latestEdition(999)); - $compareIds([19], (new Context)->subscription(5)->latestEdition(19)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); - // get items relative to article ID - $compareIds([1,2,3], (new Context)->latestArticle(3)); - $compareIds([19,20], (new Context)->oldestArticle(19)); - // get items relative to (feed) modification date - $exp = [2,4,6,8,20]; - $compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); - $exp = [1,3,5,7,19]; - $compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); - // get items relative to (user) modification date (both marks and labels apply) - $compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); - $compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); - $compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); - $compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); - // paged results - $compareIds([1], (new Context)->limit(1)); - $compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); - $compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); - $compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); - // reversed results - $compareIds([20], (new Context)->reverse(true)->limit(1)); - $compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); - $compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); - $compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); - // get articles by label ID - $compareIds([1,19], (new Context)->label(1)); - $compareIds([1,5,20], (new Context)->label(2)); - // get articles by label name - $compareIds([1,19], (new Context)->labelName("Interesting")); - $compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // get articles with any or no label - $compareIds([1,5,8,19,20], (new Context)->labelled(true)); - $compareIds([2,3,4,6,7], (new Context)->labelled(false)); - // get a specific article or edition - $compareIds([20], (new Context)->article(20)); - $compareIds([20], (new Context)->edition(1001)); - // get multiple specific articles or editions - $compareIds([1,20], (new Context)->articles([1,20,50])); - $compareIds([1,20], (new Context)->editions([1,1001,50])); - // get articles base on whether or not they have notes - $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $compareIds([2], (new Context)->annotated(true)); - // get specific starred articles - $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); - // get items that match search terms - $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); - $compareIds([1], (new Context)->searchTerms(["one", "first"])); - // get items that match search terms in note - $compareIds([2], (new Context)->annotationTerms(["some"])); - $compareIds([2], (new Context)->annotationTerms(["some", "note"])); - $compareIds([2], (new Context)->annotationTerms(["some note"])); - $compareIds([], (new Context)->annotationTerms(["some", "sauce"])); + /** @dataProvider provideContextMatches */ + public function testListArticlesCheckingContext(Context $c, array $exp) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + } + + public function provideContextMatches() { + return [ + "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], + "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], + "Leaf folder" => [(new Context)->folder(6), [7,8]], + "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], + "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], + "Subscription" => [(new Context)->subscription(5), [19,20]], + "Unread" => [(new Context)->subscription(5)->unread(true), [20]], + "Read" => [(new Context)->subscription(5)->unread(false), [19]], + "Starred" => [(new Context)->starred(true), [1,20]], + "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], + "Starred and Read in subscription" => [(new Context)->starred(true)->unread(false)->subscription(5), []], + "Annotated" => [(new Context)->annotated(true), [2]], + "Not annotated" => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], + "Labelled" => [(new Context)->labelled(true), [1,5,8,19,20]], + "Not labelled" => [(new Context)->labelled(false), [2,3,4,6,7]], + "Not after edition 999" => [(new Context)->subscription(5)->latestEdition(999), [19]], + "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], + "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], + "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], + "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], + "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + "With label ID 1" => [(new Context)->label(1), [1,19]], + "With label ID 2" => [(new Context)->label(2), [1,5,20]], + "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], + "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], + "Article ID 20" => [(new Context)->article(20), [20]], + "Edition ID 1001" => [(new Context)->edition(1001), [20]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], + "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], + "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], + "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], + "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], + "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], + "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], + "Search title 4" => [(new Context)->titleTerms(["two title"]), []], + "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], + "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], + "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], + "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], + "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], + "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + ]; } public function testListArticlesOfAMissingFolder() {