From 0dc82f64d5ae2764a2e8f4b95708790b27680345 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:11:42 -0500 Subject: [PATCH] Allow ranges in exclusion contexts --- lib/Context/Context.php | 46 -------------- lib/Context/ExclusionContext.php | 45 ++++++++++++++ lib/Database.php | 83 ++++++++++++++++---------- lib/Misc/Query.php | 3 + tests/cases/Database/SeriesArticle.php | 80 +++++++++++++------------ 5 files changed, 141 insertions(+), 116 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 4ae2e87..922c535 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -6,8 +6,6 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; - class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; @@ -18,14 +16,6 @@ class Context extends ExclusionContext { public $starred; public $labelled; public $annotated; - public $oldestArticle; - public $latestArticle; - public $oldestEdition; - public $latestEdition; - public $modifiedSince; - public $notModifiedSince; - public $markedSince; - public $notMarkedSince; public function __construct() { $this->not = new ExclusionContext($this); @@ -67,40 +57,4 @@ class Context extends ExclusionContext { public function annotated(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function latestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function latestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function markedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index b2954e3..cfec246 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; @@ -22,6 +23,14 @@ class ExclusionContext { public $searchTerms; public $titleTerms; public $authorTerms; + public $oldestArticle; + public $latestArticle; + public $oldestEdition; + public $latestEdition; + public $modifiedSince; + public $notModifiedSince; + public $markedSince; + public $notMarkedSince; protected $props = []; protected $parent; @@ -152,4 +161,40 @@ class ExclusionContext { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/lib/Database.php b/lib/Database.php index 6f7a9b4..13f49bf 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1179,32 +1179,34 @@ class Database { // 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"); } - $excContext = new ExclusionContext; // handle the simple context options $options = [ - // 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], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, 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", "latestArticle", 1], + "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], + "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], + "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], + "folderShallow" => ["folder", "=", "int", "", 1], + "subscription" => ["subscription", "=", "int", "", 1], + "unread" => ["unread", "=", "bool", "", 1], + "starred" => ["starred", "=", "bool", "", 1], ]; - foreach ($options as $m => list($col, $op, $type, $max)) { + $optionsSeen = []; + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!$context->$m()) { // context is not being used continue; } elseif (is_array($context->$m)) { + // context option is an array of values 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) { @@ -1212,27 +1214,42 @@ class Database { } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + } else { + // option has already been paired + continue; + } } else { $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } } - if ($context->not != $excContext) { - // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $max)) { - if (!method_exists($context->not, $m) || !$context->not->$m()) { - // context option is not being used + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty continue; - } elseif (is_array($context->not->$m)) { - if (!$context->not->$m) { - // for exclusions we don't care if the array is empty - } elseif (sizeof($context->not->$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->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->not->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { - $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->$m); + // option has already been paired + continue; } + } else { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } // handle complex context options diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 458b7ed..5a1b0b8 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -113,6 +113,9 @@ class Query { $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qWhereNot = []; + $this->tWhereNot = []; + $this->vWhereNot = []; $this->qJoin = []; $this->tJoin = []; $this->vJoin = []; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 37a5311..85b4c22 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -377,43 +377,6 @@ trait SeriesArticle { unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); } - public function testRetrieveArticleIdsForEditions() { - $exp = [ - 1 => 1, - 2 => 2, - 3 => 3, - 4 => 4, - 5 => 5, - 6 => 6, - 7 => 7, - 8 => 8, - 9 => 9, - 10 => 10, - 11 => 11, - 12 => 12, - 13 => 13, - 14 => 14, - 15 => 15, - 16 => 16, - 17 => 17, - 18 => 18, - 19 => 19, - 20 => 20, - 101 => 101, - 102 => 102, - 103 => 103, - 104 => 104, - 105 => 105, - 202 => 102, - 203 => 103, - 204 => 104, - 205 => 105, - 305 => 105, - 1001 => 20, - ]; - $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); - } - /** @dataProvider provideContextMatches */ public function testListArticlesCheckingContext(Context $c, array $exp) { $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); @@ -454,6 +417,8 @@ trait SeriesArticle { "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]], + "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], "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]], @@ -483,9 +448,50 @@ trait SeriesArticle { "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"]), []], + "Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]], + "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], ]; } + public function testRetrieveArticleIdsForEditions() { + $exp = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + 7 => 7, + 8 => 8, + 9 => 9, + 10 => 10, + 11 => 11, + 12 => 12, + 13 => 13, + 14 => 14, + 15 => 15, + 16 => 16, + 17 => 17, + 18 => 18, + 19 => 19, + 20 => 20, + 101 => 101, + 102 => 102, + 103 => 103, + 104 => 104, + 105 => 105, + 202 => 102, + 203 => 103, + 204 => 104, + 205 => 105, + 305 => 105, + 1001 => 20, + ]; + $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); + } + public function testListArticlesOfAMissingFolder() { $this->assertException("idMissing", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->folder(1));