From 73497688fcafcede6013f354a7ebd6fa0e42ae03 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 18 Apr 2022 22:04:48 -0400 Subject: [PATCH 01/36] Break contexts up into traits This will make their expansion easier and will also be useful for using typed properties later --- lib/Context/AbstractContext.php | 46 +++++ lib/Context/BooleanMethods.php | 29 +++ lib/Context/BooleanProperties.php | 15 ++ lib/Context/Context.php | 32 +--- lib/Context/ExclusionContext.php | 264 +--------------------------- lib/Context/ExclusionMethods.php | 202 +++++++++++++++++++++ lib/Context/ExclusionProperties.php | 40 +++++ 7 files changed, 341 insertions(+), 287 deletions(-) create mode 100644 lib/Context/AbstractContext.php create mode 100644 lib/Context/BooleanMethods.php create mode 100644 lib/Context/BooleanProperties.php create mode 100644 lib/Context/ExclusionMethods.php create mode 100644 lib/Context/ExclusionProperties.php diff --git a/lib/Context/AbstractContext.php b/lib/Context/AbstractContext.php new file mode 100644 index 0000000..f6065f8 --- /dev/null +++ b/lib/Context/AbstractContext.php @@ -0,0 +1,46 @@ +parent = $c; + } + + public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone + if ($this->parent) { + $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { + $this->parent = $t['object']; + } + } + } + + /** @codeCoverageIgnore */ + public function __destruct() { + unset($this->parent); + } + + protected function act(string $prop, int $set, $value) { + if ($set) { + if (is_null($value)) { + unset($this->props[$prop]); + $this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop]; + } else { + $this->props[$prop] = true; + $this->$prop = $value; + } + return $this->parent ?? $this; + } else { + return isset($this->props[$prop]); + } + } +} diff --git a/lib/Context/BooleanMethods.php b/lib/Context/BooleanMethods.php new file mode 100644 index 0000000..e28101e --- /dev/null +++ b/lib/Context/BooleanMethods.php @@ -0,0 +1,29 @@ +act(__FUNCTION__, func_num_args(), $spec); + } + + public function starred(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function hidden(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelled(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function annotated(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} diff --git a/lib/Context/BooleanProperties.php b/lib/Context/BooleanProperties.php new file mode 100644 index 0000000..a6f6901 --- /dev/null +++ b/lib/Context/BooleanProperties.php @@ -0,0 +1,15 @@ +not = new ExclusionContext($this); @@ -38,24 +38,4 @@ class Context extends ExclusionContext { public function offset(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function unread(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function starred(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function hidden(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function labelled(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function annotated(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index e7323ea..d72e814 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -6,265 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\ValueInfo; -use JKingWeb\Arsse\Misc\Date; - -class ExclusionContext { - public $folder; - public $folders; - public $folderShallow; - public $foldersShallow; - public $tag; - public $tags; - public $tagName; - public $tagNames; - public $subscription; - public $subscriptions; - public $edition; - public $editions; - public $article; - public $articles; - public $label; - public $labels; - public $labelName; - public $labelNames; - public $annotationTerms; - 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; - - public function __construct(self $c = null) { - $this->parent = $c; - } - - public function __clone() { - // if the context was cloned because its parent was cloned, change the parent to the clone - if ($this->parent) { - $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; - if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { - $this->parent = $t['object']; - } - } - } - - /** @codeCoverageIgnore */ - public function __destruct() { - unset($this->parent); - } - - protected function act(string $prop, int $set, $value) { - if ($set) { - if (is_null($value)) { - unset($this->props[$prop]); - $this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop]; - } else { - $this->props[$prop] = true; - $this->$prop = $value; - } - return $this->parent ?? $this; - } else { - return isset($this->props[$prop]); - } - } - - protected function cleanIdArray(array $spec, bool $allowZero = false): array { - $spec = array_values($spec); - for ($a = 0; $a < sizeof($spec); $a++) { - if (ValueInfo::id($spec[$a], $allowZero)) { - $spec[$a] = (int) $spec[$a]; - } else { - $spec[$a] = null; - } - } - return array_values(array_unique(array_filter($spec, function($v) { - return !is_null($v); - }))); - } - - protected function cleanStringArray(array $spec): array { - $spec = array_values($spec); - $stop = sizeof($spec); - for ($a = 0; $a < $stop; $a++) { - if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) { - $spec[$a] = $str; - } else { - unset($spec[$a]); - } - } - return array_values(array_unique($spec)); - } - - public function folder(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function folders(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec, true); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function folderShallow(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function foldersShallow(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec, true); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function tag(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function tags(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function tagName(string $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function tagNames(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function subscription(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function subscriptions(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function edition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function article(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function editions(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function articles(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function label(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function labels(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanIdArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function labelName(string $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function labelNames(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function annotationTerms(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function searchTerms(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function titleTerms(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function authorTerms(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanStringArray($spec); - } - 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); - } +class ExclusionContext extends AbstractContext { + use ExclusionMethods; + use ExclusionProperties; } diff --git a/lib/Context/ExclusionMethods.php b/lib/Context/ExclusionMethods.php new file mode 100644 index 0000000..7b7ecc4 --- /dev/null +++ b/lib/Context/ExclusionMethods.php @@ -0,0 +1,202 @@ +act(__FUNCTION__, func_num_args(), $spec); + } + + public function folders(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function folderShallow(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function foldersShallow(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tag(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tags(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tagName(string $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tagNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function subscription(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function subscriptions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function edition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function article(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function editions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function articles(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function label(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labels(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelName(string $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function annotationTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function searchTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function titleTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function authorTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + 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/ExclusionProperties.php b/lib/Context/ExclusionProperties.php new file mode 100644 index 0000000..426adb2 --- /dev/null +++ b/lib/Context/ExclusionProperties.php @@ -0,0 +1,40 @@ + Date: Tue, 19 Apr 2022 20:19:51 -0400 Subject: [PATCH 02/36] Retrofits dates to use ranges Article and edition ranges still need work --- lib/Context/ExclusionMethods.php | 48 ++++---- lib/Context/ExclusionProperties.php | 12 +- lib/Database.php | 62 ++++------ lib/REST/Fever/API.php | 4 +- lib/REST/Miniflux/V1.php | 3 +- lib/REST/NextcloudNews/V1_2.php | 2 +- lib/REST/TinyTinyRSS/API.php | 20 ++-- lib/REST/TinyTinyRSS/Search.php | 7 +- tests/cases/Database/SeriesArticle.php | 26 ++-- tests/cases/Misc/TestContext.php | 14 +-- tests/cases/REST/Fever/TestAPI.php | 4 +- tests/cases/REST/Miniflux/TestV1.php | 5 +- tests/cases/REST/NextcloudNews/TestV1_2.php | 4 +- tests/cases/REST/TinyTinyRSS/TestAPI.php | 126 ++++++++++---------- tests/cases/REST/TinyTinyRSS/TestSearch.php | 12 +- 15 files changed, 161 insertions(+), 188 deletions(-) diff --git a/lib/Context/ExclusionMethods.php b/lib/Context/ExclusionMethods.php index 7b7ecc4..917326e 100644 --- a/lib/Context/ExclusionMethods.php +++ b/lib/Context/ExclusionMethods.php @@ -164,39 +164,39 @@ trait ExclusionMethods { 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); + public function articleRange(?int $start = null, ?int $end = null) { + if ($start === null && $end === null) { + $spec = null; + } else { + $spec = [$start, $end]; + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); + public function editionRange(?int $start = null, ?int $end = null) { + if ($start === null && $end === null) { + $spec = null; + } else { + $spec = [$start, $end]; + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function markedSince($spec = null) { - $spec = Date::normalize($spec); + public function modifiedRange($start = null, $end = null) { + if ($start === null && $end === null) { + $spec = null; + } else { + $spec = [Date::normalize($start), Date::normalize($end)]; + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); + public function markedRange($start = null, $end = null) { + if ($start === null && $end === null) { + $spec = null; + } else { + $spec = [Date::normalize($start), Date::normalize($end)]; + } return $this->act(__FUNCTION__, func_num_args(), $spec); } } diff --git a/lib/Context/ExclusionProperties.php b/lib/Context/ExclusionProperties.php index 426adb2..8b0b63b 100644 --- a/lib/Context/ExclusionProperties.php +++ b/lib/Context/ExclusionProperties.php @@ -29,12 +29,8 @@ trait ExclusionProperties { public $searchTerms = null; public $titleTerms = null; public $authorTerms = null; - public $oldestArticle = null; - public $latestArticle = null; - public $oldestEdition = null; - public $latestEdition = null; - public $modifiedSince = null; - public $notModifiedSince = null; - public $markedSince = null; - public $notMarkedSince = null; + public $articleRange = [null, null]; + public $editionRange = [null, null]; + public $modifiedRange = [null, null]; + public $markedRange = [null, null]; } diff --git a/lib/Database.php b/lib/Database.php index f3320ce..6f63395 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1556,31 +1556,30 @@ class Database { $q->setLimit($context->limit, $context->offset); // 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 option to pair with for BETWEEN evaluation - "edition" => ["edition", "=", "int", ""], - "editions" => ["edition", "in", "int", ""], - "article" => ["id", "=", "int", ""], - "articles" => ["id", "in", "int", ""], - "oldestArticle" => ["id", ">=", "int", "latestArticle"], - "latestArticle" => ["id", "<=", "int", "oldestArticle"], - "oldestEdition" => ["edition", ">=", "int", "latestEdition"], - "latestEdition" => ["edition", "<=", "int", "oldestEdition"], - "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"], - "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"], - "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], - "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], - "folderShallow" => ["folder", "=", "int", ""], - "foldersShallow" => ["folder", "in", "int", ""], - "subscription" => ["subscription", "=", "int", ""], - "subscriptions" => ["subscription", "in", "int", ""], - "unread" => ["unread", "=", "bool", ""], - "starred" => ["starred", "=", "bool", ""], - "hidden" => ["hidden", "=", "bool", ""], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, and a data type; the "between" operator has special handling + "edition" => ["edition", "=", "int"], + "editions" => ["edition", "in", "int"], + "article" => ["id", "=", "int"], + "articles" => ["id", "in", "int"], + "articleRange" => ["id", "between", "int"], + "editionRange" => ["edition", "between", "int"], + "modifiedRange" => ["modified_date", "between", "datetime"], + "markedRange" => ["marked_date", "between", "datetime"], + "folderShallow" => ["folder", "=", "int"], + "foldersShallow" => ["folder", "in", "int"], + "subscription" => ["subscription", "=", "int"], + "subscriptions" => ["subscription", "in", "int"], + "unread" => ["unread", "=", "bool"], + "starred" => ["starred", "=", "bool"], + "hidden" => ["hidden", "=", "bool"], ]; - foreach ($options as $m => [$col, $op, $type, $pair]) { + foreach ($options as $m => [$col, $op, $type]) { if (!$context->$m()) { // context is not being used continue; + } elseif ($op === "between") { + // option is a range + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m); } elseif (is_array($context->$m)) { // context option is an array of values if (!$context->$m) { @@ -1588,23 +1587,18 @@ class Database { } [$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); } } // further handle exclusionary options if specified - foreach ($options as $m => [$col, $op, $type, $pair]) { + foreach ($options as $m => [$col, $op, $type]) { if (!method_exists($context->not, $m) || !$context->not->$m()) { // context option is not being used continue; + } elseif ($op === "between") { + // option is a range + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m); } elseif (is_array($context->not->$m)) { if (!$context->not->$m) { // for exclusions we don't care if the array is empty @@ -1612,14 +1606,6 @@ class Database { } [$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 { - // option has already been paired - continue; - } } else { $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 20e6c35..c581d88 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -244,7 +244,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c = new Context; $id = $P['id']; if ($P['before']) { - $c->notMarkedSince($P['before']); + $c->markedRange(null, $P['before']); } switch ($P['mark']) { case "item": @@ -310,7 +310,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c = (new Context)->hidden(false); $lastUnread = Date::normalize($lastUnread, "sql"); $since = Date::sub("PT15S", $lastUnread); - $c->unread(false)->markedSince($since); + $c->unread(false)->markedRange($since, null); Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); } diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index 7cba406..ca8535c 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -893,8 +893,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { ->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences ->offset($query['offset']) ->starred($query['starred']) - ->modifiedSince($query['after']) // FIXME: This may not be the correct date field - ->notModifiedSince($query['before']) + ->modifiedRange($query['after'], $query['before']) // FIXME: This may not be the correct date field ->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition ->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) ->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php index 111cf2f..21bc6fb 100644 --- a/lib/REST/NextcloudNews/V1_2.php +++ b/lib/REST/NextcloudNews/V1_2.php @@ -556,7 +556,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // whether to return only updated items if ($data['lastModified']) { - $c->markedSince($data['lastModified']); + $c->markedRange($data['lastModified'], null); } // perform the fetch try { diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 74f315a..757d476 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -256,7 +256,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetCounters(array $data): array { $user = Arsse::$user->id; $starred = Arsse::$db->articleStarred($user); - $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); + $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)); $countAll = 0; $countSubs = 0; $feeds = []; @@ -361,7 +361,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'id' => "FEED:".self::FEED_FRESH, 'bare_id' => self::FEED_FRESH, 'icon' => "images/fresh.png", - 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)), + 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)), ], $tSpecial), array_merge([ // Starred articles 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"), @@ -545,7 +545,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // FIXME: this is pretty inefficient $f = $map[self::CAT_SPECIAL]; $cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred - $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); // fresh + $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)); // fresh if (!$read) { // if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties) $count = sizeof($cats); @@ -697,7 +697,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if ($cat == self::CAT_ALL || $cat == self::CAT_SPECIAL) { // gather some statistics $starred = Arsse::$db->articleStarred($user)['unread']; - $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); + $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)); $global = Arsse::$db->articleCount($user, (new Context)->unread(true)->hidden(false)); $published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly $archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself @@ -1096,7 +1096,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly return $out; case self::FEED_FRESH: - $c->modifiedSince(Date::sub("PT24H", $this->now())); + $c->modifiedRange(Date::sub("PT24H", $this->now()), null); break; case self::FEED_ALL: // no context needed here @@ -1112,13 +1112,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } switch ($mode) { case "2week": - $c->notModifiedSince(Date::sub("P2W", $this->now())); + $c->modifiedRange($c->modifiedRange[0], Date::sub("P2W", $this->now())); break; case "1week": - $c->notModifiedSince(Date::sub("P1W", $this->now())); + $c->modifiedRange($c->modifiedRange[0], Date::sub("P1W", $this->now())); break; case "1day": - $c->notModifiedSince(Date::sub("PT24H", $this->now())); + $c->modifiedRange($c->modifiedRange[0], Date::sub("PT24H", $this->now())); } // perform the marking try { @@ -1473,13 +1473,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: if the Published feed is implemented, the headline function needs to be modified accordingly return new ResultEmpty; case self::FEED_FRESH: - $c->modifiedSince(Date::sub("PT24H", $this->now()))->unread(true); + $c->modifiedRange(Date::sub("PT24H", $this->now()), null)->unread(true); break; case self::FEED_ALL: // no context needed here break; case self::FEED_READ: - $c->markedSince(Date::sub("PT24H", $this->now()))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one + $c->markedRange(Date::sub("PT24H", $this->now()), null)->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one break; default: // any actual feed diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index ea3dbe6..966ea20 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -320,16 +320,15 @@ class Search { $end = $day."T23:59:59+00:00"; // if a date is already set, the same date is a no-op; anything else is a contradiction $cc = $neg ? $c->not : $c; - if ($cc->modifiedSince() || $cc->notModifiedSince()) { - if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) { + if ($cc->modifiedRange()) { + if (!$cc->modifiedRange[0] || !$cc->modifiedRange[1] || $cc->modifiedRange[0]->format("c") !== $start || $cc->modifiedRange[1]->format("c") !== $end) { // FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this throw new Exception; } else { return $c; } } - $cc->modifiedSince($start); - $cc->notModifiedSince($end); + $cc->modifiedRange($start, $end); return $c; } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index eace73a..82bcef8 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -462,16 +462,16 @@ trait SeriesArticle { '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]], - '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]], + 'Modified by author since 2005' => [(new Context)->modifiedRange("2005-01-01T00:00:00Z", null), [2,4,6,8,20]], + 'Modified by author since 2010' => [(new Context)->modifiedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,20]], + 'Not modified by author since 2005' => [(new Context)->modifiedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Not modified by author since 2000' => [(new Context)->modifiedRange(null, "2000-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Marked or labelled since 2014' => [(new Context)->markedRange("2014-01-01T00:00:00Z", null), [8,19]], + 'Marked or labelled since 2010' => [(new Context)->markedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,19,20]], + 'Not marked or labelled since 2014' => [(new Context)->markedRange(null, "2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + 'Not marked or labelled since 2005' => [(new Context)->markedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7]], + 'Marked or labelled between 2000 and 2015' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + 'Marked or labelled in 2010' => [(new Context)->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [2,4,6,20]], 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], @@ -505,7 +505,7 @@ trait SeriesArticle { '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]], + 'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59")->not->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [1,3,5,7,8]], 'Search with exclusion' => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], @@ -953,7 +953,7 @@ trait SeriesArticle { } public function testMarkByLastMarked(): void { - Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedSince('2017-01-01T00:00:00Z')); + Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange('2017-01-01T00:00:00Z', null)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][8][3] = 1; @@ -964,7 +964,7 @@ trait SeriesArticle { } public function testMarkByNotLastMarked(): void { - Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->notMarkedSince('2000-01-01T00:00:00Z')); + Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange(null, '2000-01-01T00:00:00Z')); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0]; diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 46ecaaf..7e1d6af 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -47,10 +47,6 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'unread' => true, 'starred' => true, 'hidden' => true, - 'modifiedSince' => new \DateTime(), - 'notModifiedSince' => new \DateTime(), - 'markedSince' => new \DateTime(), - 'notMarkedSince' => new \DateTime(), 'editions' => [1,2], 'articles' => [1,2], 'label' => 2112, @@ -65,21 +61,17 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'authorTerms' => ["foo", "bar"], 'not' => (new Context)->subscription(5), ]; - $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; + $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isStatic() || strpos($m->name, "__") === 0) { + if ($m->isStatic() || strpos($m->name, "__") === 0 || in_array($m->name, $ranges)) { continue; } $method = $m->name; $this->assertArrayHasKey($method, $v, "Context method $method not included in test"); $this->assertInstanceOf(Context::class, $c->$method($v[$method])); $this->assertTrue($c->$method()); - if (in_array($method, $times)) { - $this->assertTime($c->$method, $v[$method], "Context method $method did not return the expected results"); - } else { - $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results"); - } + $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results"); // clear the context option $c->$method(null); $this->assertFalse($c->$method()); diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index a9896c7..4dfaec8 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -407,7 +407,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], ["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], - ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->markedRange(null, "2000-01-01T00:00:00Z"), $markRead, $listUnread], ["mark=item&as=unread", new Context, [], []], ["mark=item&id=6", new Context, [], []], ["as=unread&id=6", new Context, [], []], @@ -462,7 +462,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->dbMock->articleMark->returns(0); $exp = new JsonResponse($out); $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1])); - $this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")->hidden(false))); + $this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedRange("1999-12-31T23:59:45Z", null)->hidden(false))); $this->dbMock->articleList->with($this->userId, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->returns(new Result([])); $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1])); $this->dbMock->articleMark->once()->called(); // only called one time, above diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index f1dd8d3..a87daf6 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -768,9 +768,10 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { ["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?after=0", (clone $c)->modifiedSince(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?after=0", (clone $c)->modifiedRange(0, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before=1", (clone $c)->notModifiedSince(1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before=1", (clone $c)->modifiedRange(null, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before=1&after=0", (clone $c)->modifiedRange(0, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php index 9e980e9..b637b0f 100644 --- a/tests/cases/REST/NextcloudNews/TestV1_2.php +++ b/tests/cases/REST/NextcloudNews/TestV1_2.php @@ -695,7 +695,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200], ["/items", ['getRead' => true], clone $c, $out, $r200], ["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200], - ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200], + ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], ["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200], ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200], ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], @@ -708,7 +708,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200], ["/items/updated", ['getRead' => true], clone $c, $out, $r200], ["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200], - ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200], + ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], ["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200], ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200], ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 74a12b9..e9ed0e5 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -959,7 +959,7 @@ LONG_STRING; $this->dbMock->folderList->with("~", null, false)->returns(new Result($this->v($this->topFolders))); $this->dbMock->subscriptionList->returns(new Result($this->v($this->subscriptions))); $this->dbMock->labelList->returns(new Result($this->v($this->labels))); - $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7); + $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null)))->returns(7); $this->dbMock->articleStarred->returns($this->v($this->starred)); $this->assertMessage($exp, $this->req($in)); } @@ -1060,7 +1060,7 @@ LONG_STRING; ['id' => -2, 'kind' => "cat", 'counter' => 6], ]; $this->assertMessage($this->respGood($exp), $this->req($in)); - $this->dbMock->articleCount->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW)))); + $this->dbMock->articleCount->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null))); } /** @dataProvider provideLabelListings */ @@ -1152,7 +1152,7 @@ LONG_STRING; $this->assertMessage($this->respGood($exp), $this->req($in[0])); $exp = ['categories' => ['identifier' => 'id','label' => 'name','items' => [['name' => 'Special','id' => 'CAT:-1','bare_id' => -1,'type' => 'category','unread' => 0,'items' => [['name' => 'All articles','id' => 'FEED:-4','bare_id' => -4,'icon' => 'images/folder.png','unread' => 35,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Fresh articles','id' => 'FEED:-3','bare_id' => -3,'icon' => 'images/fresh.png','unread' => 7,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Starred articles','id' => 'FEED:-1','bare_id' => -1,'icon' => 'images/star.png','unread' => 4,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Published articles','id' => 'FEED:-2','bare_id' => -2,'icon' => 'images/feed.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Archived articles','id' => 'FEED:0','bare_id' => 0,'icon' => 'images/archive.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Recently read','id' => 'FEED:-6','bare_id' => -6,'icon' => 'images/time.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => '']]],['name' => 'Labels','id' => 'CAT:-2','bare_id' => -2,'type' => 'category','unread' => 6,'items' => [['name' => 'Fascinating','id' => 'FEED:-1027','bare_id' => -1027,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Interesting','id' => 'FEED:-1029','bare_id' => -1029,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Logical','id' => 'FEED:-1025','bare_id' => -1025,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => '']]],['name' => 'Politics','id' => 'CAT:3','bare_id' => 3,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(3 feeds)','items' => [['name' => 'Local','id' => 'CAT:5','bare_id' => 5,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'Toronto Star','id' => 'FEED:2','bare_id' => 2,'icon' => 'feed-icons/2.ico','error' => 'oops','param' => '2011-11-11T11:11:11Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'National','id' => 'CAT:6','bare_id' => 6,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'CBC News','id' => 'FEED:4','bare_id' => 4,'icon' => 'feed-icons/4.ico','error' => '','param' => '2017-10-09T15:58:34Z','unread' => 0,'auxcounter' => 0,'checkbox' => false],['name' => 'Ottawa Citizen','id' => 'FEED:5','bare_id' => 5,'icon' => false,'error' => '','param' => '2017-07-07T17:07:17Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]],['name' => 'Science','id' => 'CAT:1','bare_id' => 1,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'Rocketry','id' => 'CAT:2','bare_id' => 2,'parent_id' => 1,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'NASA JPL','id' => 'FEED:1','bare_id' => 1,'icon' => false,'error' => '','param' => '2017-09-15T22:54:16Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Ars Technica','id' => 'FEED:3','bare_id' => 3,'icon' => 'feed-icons/3.ico','error' => 'argh','param' => '2016-05-23T06:40:02Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Uncategorized','id' => 'CAT:0','bare_id' => 0,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'parent_id' => null,'param' => '(1 feed)','items' => [['name' => 'Eurogamer','id' => 'FEED:6','bare_id' => 6,'icon' => 'feed-icons/6.ico','error' => '','param' => '2010-02-12T20:08:47Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]]]; $this->assertMessage($this->respGood($exp), $this->req($in[1])); - $this->dbMock->articleCount->twice()->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW)))); + $this->dbMock->articleCount->twice()->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null))); } /** @dataProvider provideMassMarkings */ @@ -1180,8 +1180,8 @@ LONG_STRING; [['feed_id' => 0, 'is_cat' => true, 'mode' => "bogus"], (clone $c)->folderShallow(0)], [['feed_id' => -1], (clone $c)->starred(true)], [['feed_id' => -1, 'is_cat' => "t"], null], - [['feed_id' => -3], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))], - [['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))->notModifiedSince(Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do + [['feed_id' => -3], (clone $c)->modifiedRange(Date::sub("PT24H", self::NOW), null)], + [['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedRange(Date::sub("PT24H", self::NOW), Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do [['feed_id' => -3, 'is_cat' => true], null], [['feed_id' => -2], null], [['feed_id' => -2, 'is_cat' => true], (clone $c)->labelled(true)], @@ -1191,9 +1191,9 @@ LONG_STRING; [['feed_id' => -6, 'is_cat' => "f"], null], [['feed_id' => -2112], (clone $c)->label(1088)], [['feed_id' => 42, 'is_cat' => true], (clone $c)->folder(42)], - [['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->notModifiedSince(Date::sub("P1W", self::NOW))], + [['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->modifiedRange(null, Date::sub("P1W", self::NOW))], [['feed_id' => 2112], (clone $c)->subscription(2112)], - [['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->notModifiedSince(Date::sub("P2W", self::NOW))], + [['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->modifiedRange(null, Date::sub("P2W", self::NOW))], ]; } @@ -1202,7 +1202,7 @@ LONG_STRING; $in = array_merge(['op' => "getFeeds", 'sid' => "PriestsOfSyrinx"], $in); // statistical mocks $this->dbMock->articleStarred->returns($this->v($this->starred)); - $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7); + $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedRange(Date::sub("PT24H", self::NOW), null)))->returns(7); $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(35); // label mocks $this->dbMock->labelList->returns(new Result($this->v($this->labels))); @@ -1521,61 +1521,61 @@ LONG_STRING; $fields = ["id", "guid", "title", "author", "url", "unread", "starred", "edited_date", "published_date", "subscription", "subscription_title", "note"]; $sort = ["edited_date desc"]; return [ - [true, [], null, $c, [], [], $this->respErr("INCORRECT_USAGE")], - [true, ['feed_id' => 0], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => -1], $out, (clone $c)->starred(true), $fields, ["marked_date desc"], $expFull], - [true, ['feed_id' => -2], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => -4], $out, $c, $fields, $sort, $expFull], - [true, ['feed_id' => 2112], $gone, (clone $c)->subscription(2112), $fields, $sort, $this->respGood([])], - [true, ['feed_id' => -2112], $out, (clone $c)->label(1088), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'view_mode' => "adaptive"], $out, (clone $c)->unread(true), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'view_mode' => "published"], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => -2112, 'view_mode' => "adaptive"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull], - [true, ['feed_id' => -2112, 'view_mode' => "unread"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull], - [true, ['feed_id' => 42, 'view_mode' => "marked"], $out, (clone $c)->subscription(42)->starred(true), $fields, $sort, $expFull], - [true, ['feed_id' => 42, 'view_mode' => "has_note"], $out, (clone $c)->subscription(42)->annotated(true), $fields, $sort, $expFull], - [true, ['feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => 42, 'search' => "pub:true"], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull], - [true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], - [true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull], - [true, ['feed_id' => -1, 'is_cat' => true], null, $c, [], [], $this->respGood([])], - [true, ['feed_id' => 0, 'is_cat' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull], - [true, ['feed_id' => 0, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull], - [true, ['feed_id' => 42, 'is_cat' => true], $out, (clone $c)->folderShallow(42), $fields, $sort, $expFull], - [true, ['feed_id' => 42, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folder(42), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'order_by' => "feed_dates"], $out, $c, $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'order_by' => "date_reverse"], $out, $c, $fields, ["edited_date"], $expFull], - [true, ['feed_id' => 42, 'search' => "interesting"], $out, (clone $c)->subscription(42)->searchTerms(["interesting"]), $fields, $sort, $expFull], - [true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedSince(Date::sub("PT24H", $t)), $fields, ["marked_date desc"], $expFull], - [true, ['feed_id' => -6, 'view_mode' => "unread"], null, $c, $fields, $sort, $this->respGood([])], - [true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull], - [true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull], - [false, [], null, (clone $c)->limit(null), [], [], $this->respErr("INCORRECT_USAGE")], - [false, ['feed_id' => 0], null, (clone $c)->limit(null), [], [], $this->respGood([])], - [false, ['feed_id' => -1], $comp, (clone $c)->limit(null)->starred(true), ["id"], ["marked_date desc"], $expComp], - [false, ['feed_id' => -2], null, (clone $c)->limit(null), [], [], $this->respGood([])], - [false, ['feed_id' => -4], $comp, (clone $c)->limit(null), ["id"], $sort, $expComp], - [false, ['feed_id' => 2112], $gone, (clone $c)->limit(null)->subscription(2112), ["id"], $sort, $this->respGood([])], - [false, ['feed_id' => -2112], $comp, (clone $c)->limit(null)->label(1088), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->unread(true), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'view_mode' => "published"], null, (clone $c)->limit(null), [], [], $this->respGood([])], - [false, ['feed_id' => -2112, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp], - [false, ['feed_id' => -2112, 'view_mode' => "unread"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp], - [false, ['feed_id' => 42, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->subscription(42)->starred(true), ["id"], $sort, $expComp], - [false, ['feed_id' => 42, 'view_mode' => "has_note"], $comp, (clone $c)->limit(null)->subscription(42)->annotated(true), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp], - [false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedSince(Date::sub("PT24H", $t)), ["id"], ["marked_date desc"], $expComp], - [false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])], - [false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp], - [false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp], + [true, [], null, $c, [], [], $this->respErr("INCORRECT_USAGE")], + [true, ['feed_id' => 0], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => -1], $out, (clone $c)->starred(true), $fields, ["marked_date desc"], $expFull], + [true, ['feed_id' => -2], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => -4], $out, $c, $fields, $sort, $expFull], + [true, ['feed_id' => 2112], $gone, (clone $c)->subscription(2112), $fields, $sort, $this->respGood([])], + [true, ['feed_id' => -2112], $out, (clone $c)->label(1088), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'view_mode' => "adaptive"], $out, (clone $c)->unread(true), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'view_mode' => "published"], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => -2112, 'view_mode' => "adaptive"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull], + [true, ['feed_id' => -2112, 'view_mode' => "unread"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull], + [true, ['feed_id' => 42, 'view_mode' => "marked"], $out, (clone $c)->subscription(42)->starred(true), $fields, $sort, $expFull], + [true, ['feed_id' => 42, 'view_mode' => "has_note"], $out, (clone $c)->subscription(42)->annotated(true), $fields, $sort, $expFull], + [true, ['feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => 42, 'search' => "pub:true"], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull], + [true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], + [true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull], + [true, ['feed_id' => -1, 'is_cat' => true], null, $c, [], [], $this->respGood([])], + [true, ['feed_id' => 0, 'is_cat' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull], + [true, ['feed_id' => 0, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull], + [true, ['feed_id' => 42, 'is_cat' => true], $out, (clone $c)->folderShallow(42), $fields, $sort, $expFull], + [true, ['feed_id' => 42, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folder(42), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'order_by' => "feed_dates"], $out, $c, $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'order_by' => "date_reverse"], $out, $c, $fields, ["edited_date"], $expFull], + [true, ['feed_id' => 42, 'search' => "interesting"], $out, (clone $c)->subscription(42)->searchTerms(["interesting"]), $fields, $sort, $expFull], + [true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedRange(Date::sub("PT24H", $t), null), $fields, ["marked_date desc"], $expFull], + [true, ['feed_id' => -6, 'view_mode' => "unread"], null, $c, $fields, $sort, $this->respGood([])], + [true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedRange(Date::sub("PT24H", $t), null), $fields, $sort, $expFull], + [true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedRange(Date::sub("PT24H", $t), null), $fields, $sort, $expFull], + [false, [], null, (clone $c)->limit(null), [], [], $this->respErr("INCORRECT_USAGE")], + [false, ['feed_id' => 0], null, (clone $c)->limit(null), [], [], $this->respGood([])], + [false, ['feed_id' => -1], $comp, (clone $c)->limit(null)->starred(true), ["id"], ["marked_date desc"], $expComp], + [false, ['feed_id' => -2], null, (clone $c)->limit(null), [], [], $this->respGood([])], + [false, ['feed_id' => -4], $comp, (clone $c)->limit(null), ["id"], $sort, $expComp], + [false, ['feed_id' => 2112], $gone, (clone $c)->limit(null)->subscription(2112), ["id"], $sort, $this->respGood([])], + [false, ['feed_id' => -2112], $comp, (clone $c)->limit(null)->label(1088), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->unread(true), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'view_mode' => "published"], null, (clone $c)->limit(null), [], [], $this->respGood([])], + [false, ['feed_id' => -2112, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp], + [false, ['feed_id' => -2112, 'view_mode' => "unread"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp], + [false, ['feed_id' => 42, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->subscription(42)->starred(true), ["id"], $sort, $expComp], + [false, ['feed_id' => 42, 'view_mode' => "has_note"], $comp, (clone $c)->limit(null)->subscription(42)->annotated(true), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp], + [false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedRange(Date::sub("PT24H", $t), null), ["id"], ["marked_date desc"], $expComp], + [false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])], + [false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedRange(Date::sub("PT24H", $t), null), ["id"], $sort, $expComp], + [false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedRange(Date::sub("PT24H", $t), null), ["id"], $sort, $expComp], ]; } diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php index 6999b0d..84ca200 100644 --- a/tests/cases/REST/TinyTinyRSS/TestSearch.php +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -101,10 +101,10 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { 'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], 'Bare blank date' => ['@', new Context], 'Quoted blank date' => ['"@"', new Context], - 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], - 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], - 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], - 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], 'Invalid date' => ['@Bugaboo', new Context], 'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], 'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], @@ -112,8 +112,8 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { 'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], 'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], 'Contradictory dates' => ['@Yesterday @Today', null], - 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], - 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], ]; } From 983fa58ec8cfd6aad5254cc39cf48bc9f8d2bb6b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Apr 2022 22:53:36 -0400 Subject: [PATCH 03/36] Convert article and edition ranges to atomic Unit tests for ranges are still missing --- lib/Database.php | 22 +++++++- lib/REST/Fever/API.php | 4 +- lib/REST/Miniflux/V1.php | 3 +- lib/REST/NextcloudNews/V1_2.php | 10 ++-- lib/REST/TinyTinyRSS/API.php | 2 +- tests/cases/Database/SeriesArticle.php | 18 +++--- tests/cases/Misc/TestContext.php | 15 ++--- tests/cases/REST/Fever/TestAPI.php | 8 +-- tests/cases/REST/Miniflux/TestV1.php | 4 +- tests/cases/REST/NextcloudNews/TestV1_2.php | 62 ++++++++++----------- tests/cases/REST/TinyTinyRSS/TestAPI.php | 4 +- 11 files changed, 85 insertions(+), 67 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 6f63395..5781f77 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1579,7 +1579,16 @@ class Database { continue; } elseif ($op === "between") { // option is a range - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m); + if ($context->$m[0] === null) { + // range is open at the low end + $q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]); + } elseif ($context->$m[1] === null) { + // range is open at the high end + $q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]); + } else { + // range is bounded in both directions + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m); + } } elseif (is_array($context->$m)) { // context option is an array of values if (!$context->$m) { @@ -1598,7 +1607,16 @@ class Database { continue; } elseif ($op === "between") { // option is a range - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m); + if ($context->not->$m[0] === null) { + // range is open at the low end + $q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]); + } elseif ($context->not->$m[1] === null) { + // range is open at the high end + $q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]); + } else { + // range is bounded in both directions + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m); + } } elseif (is_array($context->not->$m)) { if (!$context->not->$m) { // for exclusions we don't care if the array is empty diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index c581d88..7ad69ba 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -388,10 +388,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if ($G['with_ids']) { $c->articles(explode(",", $G['with_ids']))->hidden(null); } elseif ($G['max_id']) { - $c->latestArticle($G['max_id'] - 1); + $c->articleRange(null, $G['max_id'] - 1); $reverse = true; } elseif ($G['since_id']) { - $c->oldestArticle($G['since_id'] + 1); + $c->articleRange($G['since_id'] + 1, null); } // handle the undocumented options if ($G['group_ids']) { diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index ca8535c..09a24f3 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -894,8 +894,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { ->offset($query['offset']) ->starred($query['starred']) ->modifiedRange($query['after'], $query['before']) // FIXME: This may not be the correct date field - ->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition - ->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) + ->articleRange($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null, $query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) // FIXME: This might be edition ->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings if ($query['category_id']) { if ($query['category_id'] === 1) { diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php index 21bc6fb..7ec195c 100644 --- a/lib/REST/NextcloudNews/V1_2.php +++ b/lib/REST/NextcloudNews/V1_2.php @@ -346,7 +346,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // build the context $c = (new Context)->hidden(false); - $c->latestEdition((int) $data['newestItemId']); + $c->editionRange(null, (int) $data['newestItemId']); $c->folder((int) $url[1]); // perform the operation try { @@ -501,7 +501,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // build the context $c = (new Context)->hidden(false); - $c->latestEdition((int) $data['newestItemId']); + $c->editionRange(null, (int) $data['newestItemId']); $c->subscription((int) $url[1]); // perform the operation try { @@ -526,9 +526,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one if ($data['offset'] > 0) { if ($reverse) { - $c->latestEdition($data['offset'] - 1); + $c->editionRange(null, $data['offset'] - 1); } else { - $c->oldestEdition($data['offset'] + 1); + $c->editionRange($data['offset'] + 1, null); } } // set whether to only return unread @@ -597,7 +597,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // build the context $c = (new Context)->hidden(false); - $c->latestEdition((int) $data['newestItemId']); + $c->editionRange(null, (int) $data['newestItemId']); // perform the operation Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); return new EmptyResponse(204); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 757d476..d214709 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1550,7 +1550,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // set the minimum article ID if ($data['since_id'] > 0) { - $c->oldestArticle($data['since_id'] + 1); + $c->articleRange($data['since_id'] + 1, null); } // return results return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 82bcef8..a1eafda 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -456,12 +456,12 @@ trait SeriesArticle { 'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,5,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]], + 'Not after edition 999' => [(new Context)->subscription(5)->editionRange(null, 999), [19]], + 'Not after edition 19' => [(new Context)->subscription(5)->editionRange(null, 19), [19]], + 'Not before edition 999' => [(new Context)->subscription(5)->editionRange(999, null), [20]], + 'Not before edition 1001' => [(new Context)->subscription(5)->editionRange(1001, null), [20]], + 'Not after article 3' => [(new Context)->articleRange(null, 3), [1,2,3]], + 'Not before article 19' => [(new Context)->articleRange(19, null), [19,20]], 'Modified by author since 2005' => [(new Context)->modifiedRange("2005-01-01T00:00:00Z", null), [2,4,6,8,20]], 'Modified by author since 2010' => [(new Context)->modifiedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,20]], 'Not modified by author since 2005' => [(new Context)->modifiedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7,19]], @@ -472,7 +472,7 @@ trait SeriesArticle { 'Not marked or labelled since 2005' => [(new Context)->markedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7]], 'Marked or labelled between 2000 and 2015' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], 'Marked or labelled in 2010' => [(new Context)->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [2,4,6,20]], - 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + 'Paged results' => [(new Context)->limit(2)->editionRange(4, null), [4,5]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], @@ -929,7 +929,7 @@ trait SeriesArticle { } public function testMarkByOldestEdition(): void { - Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->oldestEdition(19)); + Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(19, null)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][8][3] = 1; @@ -940,7 +940,7 @@ trait SeriesArticle { } public function testMarkByLatestEdition(): void { - Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->latestEdition(20)); + Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(null, 20)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][8][3] = 1; diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 7e1d6af..a05cab6 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -11,6 +11,8 @@ use JKingWeb\Arsse\Misc\ValueInfo; /** @covers \JKingWeb\Arsse\Context\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { + protected $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; + public function testVerifyInitialState(): void { $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { @@ -19,7 +21,11 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } $method = $m->name; $this->assertFalse($c->$method(), "Context method $method did not initially return false"); - $this->assertEquals(null, $c->$method, "Context property $method is not initially falsy"); + if (in_array($method, $this->ranges)) { + $this->assertEquals([null, null], $c->$method, "Context property $method is not initially a two-member falsy array"); + } else { + $this->assertEquals(null, $c->$method, "Context property $method is not initially falsy"); + } } } @@ -40,10 +46,6 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'subscriptions' => [44, 2112], 'article' => 255, 'edition' => 65535, - 'latestArticle' => 47, - 'oldestArticle' => 1337, - 'latestEdition' => 47, - 'oldestEdition' => 1337, 'unread' => true, 'starred' => true, 'hidden' => true, @@ -61,10 +63,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'authorTerms' => ["foo", "bar"], 'not' => (new Context)->subscription(5), ]; - $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isStatic() || strpos($m->name, "__") === 0 || in_array($m->name, $ranges)) { + if ($m->isStatic() || strpos($m->name, "__") === 0 || in_array($m->name, $this->ranges)) { continue; } $method = $m->name; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 4dfaec8..6a618b6 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -316,12 +316,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4])->hidden(false), false], ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4])->hidden(false), false], ["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], - ["items&since_id=1", (clone $c)->oldestArticle(2)->hidden(false), false], - ["items&max_id=2", (clone $c)->latestArticle(1)->hidden(false), true], + ["items&since_id=1", (clone $c)->articleRange(2, null)->hidden(false), false], + ["items&max_id=2", (clone $c)->articleRange(null, 1)->hidden(false), true], ["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false], ["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false], - ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2)->hidden(false), true], - ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7)->hidden(false), false], + ["items&max_id=3&since_id=6", (clone $c)->articleRange(null, 2)->hidden(false), true], + ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->articleRange(7, null)->hidden(false), false], ]; } diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index a87daf6..5a8c651 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -772,8 +772,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { ["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?before=1", (clone $c)->modifiedRange(null, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?before=1&after=0", (clone $c)->modifiedRange(0, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?after_entry_id=42", (clone $c)->articleRange(43, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before_entry_id=47", (clone $c)->articleRange(null, 46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], ["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])], ["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])], diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php index b637b0f..f58f87d 100644 --- a/tests/cases/REST/NextcloudNews/TestV1_2.php +++ b/tests/cases/REST/NextcloudNews/TestV1_2.php @@ -686,40 +686,40 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $r200 = new Response(['items' => $this->articles['rest']]); $r422 = new EmptyResponse(422); return [ - ["/items", [], clone $c, $out, $r200], - ["/items", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422], - ["/items", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422], - ["/items", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422], - ["/items", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422], - ["/items", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200], - ["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200], - ["/items", ['getRead' => true], clone $c, $out, $r200], - ["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200], - ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], - ["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200], - ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200], - ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], - ["/items/updated", [], clone $c, $out, $r200], - ["/items/updated", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422], - ["/items/updated", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422], - ["/items/updated", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422], - ["/items/updated", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422], - ["/items/updated", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200], - ["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200], - ["/items/updated", ['getRead' => true], clone $c, $out, $r200], - ["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200], - ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], - ["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200], - ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200], - ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], + ["/items", [], clone $c, $out, $r200], + ["/items", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422], + ["/items", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422], + ["/items", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422], + ["/items", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422], + ["/items", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200], + ["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200], + ["/items", ['getRead' => true], clone $c, $out, $r200], + ["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200], + ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], + ["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->editionRange(6, null)->limit(10), $out, $r200], + ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->editionRange(null, 4)->limit(5), $out, $r200], + ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], + ["/items/updated", [], clone $c, $out, $r200], + ["/items/updated", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422], + ["/items/updated", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422], + ["/items/updated", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422], + ["/items/updated", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422], + ["/items/updated", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200], + ["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200], + ["/items/updated", ['getRead' => true], clone $c, $out, $r200], + ["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200], + ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200], + ["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->editionRange(6, null)->limit(10), $out, $r200], + ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->editionRange(null, 4)->limit(5), $out, $r200], + ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200], ]; } public function testMarkAFolderRead(): void { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(1)->latestEdition(2112)->hidden(false)))->returns(42); - $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // folder doesn't exist + $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(1)->editionRange(null, 2112)->hidden(false)))->returns(42); + $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(42)->editionRange(null, 2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // folder doesn't exist $exp = new EmptyResponse(204); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in)); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112")); @@ -733,8 +733,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { public function testMarkASubscriptionRead(): void { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(1)->latestEdition(2112)->hidden(false)))->returns(42); - $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // subscription doesn't exist + $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(1)->editionRange(null, 2112)->hidden(false)))->returns(42); + $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(42)->editionRange(null, 2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // subscription doesn't exist $exp = new EmptyResponse(204); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in)); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112")); @@ -748,7 +748,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { public function testMarkAllItemsRead(): void { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->latestEdition(2112)))->returns(42); + $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->editionRange(null, 2112)))->returns(42); $exp = new EmptyResponse(204); $this->assertMessage($exp, $this->req("PUT", "/items/read", $in)); $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112")); diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index e9ed0e5..fe5c07b 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1539,7 +1539,7 @@ LONG_STRING; [true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull], [true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull], [true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull], - [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull], + [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->articleRange(48, null), $fields, $sort, $expFull], [true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], [true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull], [true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull], @@ -1571,7 +1571,7 @@ LONG_STRING; [false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp], [false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp], [false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp], - [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp], + [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->articleRange(48, null), ["id"], $sort, $expComp], [false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedRange(Date::sub("PT24H", $t), null), ["id"], ["marked_date desc"], $expComp], [false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])], [false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedRange(Date::sub("PT24H", $t), null), ["id"], $sort, $expComp], From 308b592b18f2ca179ff661337a041a9b51245126 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Apr 2022 23:20:20 -0400 Subject: [PATCH 04/36] Clean up coontext classes --- lib/Context/AbstractContext.php | 19 ---------- ...{BooleanMethods.php => BooleanMembers.php} | 8 ++++- lib/Context/BooleanProperties.php | 15 -------- lib/Context/Context.php | 6 ++-- lib/Context/ExclusionContext.php | 22 ++++++++++-- ...lusionMethods.php => ExclusionMembers.php} | 29 ++++++++++++++- lib/Context/ExclusionProperties.php | 36 ------------------- 7 files changed, 57 insertions(+), 78 deletions(-) rename lib/Context/{BooleanMethods.php => BooleanMembers.php} (82%) delete mode 100644 lib/Context/BooleanProperties.php rename lib/Context/{ExclusionMethods.php => ExclusionMembers.php} (88%) delete mode 100644 lib/Context/ExclusionProperties.php diff --git a/lib/Context/AbstractContext.php b/lib/Context/AbstractContext.php index f6065f8..d86ef39 100644 --- a/lib/Context/AbstractContext.php +++ b/lib/Context/AbstractContext.php @@ -10,25 +10,6 @@ abstract class AbstractContext { protected $props = []; protected $parent = null; - public function __construct(self $c = null) { - $this->parent = $c; - } - - public function __clone() { - // if the context was cloned because its parent was cloned, change the parent to the clone - if ($this->parent) { - $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; - if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { - $this->parent = $t['object']; - } - } - } - - /** @codeCoverageIgnore */ - public function __destruct() { - unset($this->parent); - } - protected function act(string $prop, int $set, $value) { if ($set) { if (is_null($value)) { diff --git a/lib/Context/BooleanMethods.php b/lib/Context/BooleanMembers.php similarity index 82% rename from lib/Context/BooleanMethods.php rename to lib/Context/BooleanMembers.php index e28101e..e13be6f 100644 --- a/lib/Context/BooleanMethods.php +++ b/lib/Context/BooleanMembers.php @@ -6,7 +6,13 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -trait BooleanMethods { +trait BooleanMembers { + public $unread = null; + public $starred = null; + public $hidden = null; + public $labelled = null; + public $annotated = null; + public function unread(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Context/BooleanProperties.php b/lib/Context/BooleanProperties.php deleted file mode 100644 index a6f6901..0000000 --- a/lib/Context/BooleanProperties.php +++ /dev/null @@ -1,15 +0,0 @@ -parent = $parent; + } + + public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone + if ($this->parent) { + $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + if (($t['object'] ?? null) instanceof Context && $t['function'] === "__clone") { + $this->parent = $t['object']; + } + } + } + + /** @codeCoverageIgnore */ + public function __destruct() { + unset($this->parent); + } } diff --git a/lib/Context/ExclusionMethods.php b/lib/Context/ExclusionMembers.php similarity index 88% rename from lib/Context/ExclusionMethods.php rename to lib/Context/ExclusionMembers.php index 917326e..d9d82c7 100644 --- a/lib/Context/ExclusionMethods.php +++ b/lib/Context/ExclusionMembers.php @@ -9,7 +9,34 @@ namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\Misc\Date; -trait ExclusionMethods { +trait ExclusionMembers { + public $folder = null; + public $folders = null; + public $folderShallow = null; + public $foldersShallow = null; + public $tag = null; + public $tags = null; + public $tagName = null; + public $tagNames = null; + public $subscription = null; + public $subscriptions = null; + public $edition = null; + public $editions = null; + public $article = null; + public $articles = null; + public $label = null; + public $labels = null; + public $labelName = null; + public $labelNames = null; + public $annotationTerms = null; + public $searchTerms = null; + public $titleTerms = null; + public $authorTerms = null; + public $articleRange = [null, null]; + public $editionRange = [null, null]; + public $modifiedRange = [null, null]; + public $markedRange = [null, null]; + protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { diff --git a/lib/Context/ExclusionProperties.php b/lib/Context/ExclusionProperties.php deleted file mode 100644 index 8b0b63b..0000000 --- a/lib/Context/ExclusionProperties.php +++ /dev/null @@ -1,36 +0,0 @@ - Date: Wed, 20 Apr 2022 19:11:04 -0400 Subject: [PATCH 05/36] Start to shore up testing --- tests/cases/Database/SeriesArticle.php | 2 ++ tests/cases/Misc/TestContext.php | 44 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index a1eafda..2a4aa51 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -526,6 +526,8 @@ trait SeriesArticle { 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], + 'Before article 3' => [(new Context)->not->articleRange(3, null), [1,2]], + 'Before article 19' => [(new Context)->not->articleRange(null, 19), [20]], ]; } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index a05cab6..fa05348 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -9,7 +9,10 @@ namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; -/** @covers \JKingWeb\Arsse\Context\Context */ +/** + * @covers \JKingWeb\Arsse\Context\Context + * @covers \JKingWeb\Arsse\Context\ExclusionContext + */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { protected $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; @@ -79,6 +82,45 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } + public function provideContextOptions(): iterable { + return [ + 'reverse' => [[true], true], + 'limit' => [[10], 10], + 'offset' => [[5], 5], + 'folder' => [[42], 42], + 'folders' => [[[12,22]], [12,22]], + 'folderShallow' => [[42], 42], + 'foldersShallow' => [[[0,1]], [0,1]], + 'tag' => [[44], 44], + 'tags' => [[[44, 2112]], [44, 2112]], + 'tagName' => [["XLIV"], "XLIV"], + 'tagNames' => [[["XLIV", "MMCXII"]], ["XLIV", "MMCXII"]], + 'subscription' => [[2112], 2112], + 'subscriptions' => [[[44, 2112]], [44, 2112]], + 'article' => [[255], 255], + 'edition' => [[65535], 65535], + 'unread' => [[true], true], + 'starred' => [[true], true], + 'hidden' => [[true], true], + 'editions' => [[[1,2]], [1,2]], + 'articles' => [[[1,2]], [1,2]], + 'label' => [[2112], 2112], + 'labels' => [[[2112, 1984]], [2112, 1984]], + 'labelName' => [["Rush"], "Rush"], + 'labelNames' => [[["Rush", "Orwell"]], ["Rush", "Orwell"]], + 'labelled' => [[true], true], + 'annotated' => [[true], true], + 'searchTerms' => [[["foo", "bar"]], ["foo", "bar"]], + 'annotationTerms' => [[["foo", "bar"]], ["foo", "bar"]], + 'titleTerms' => [[["foo", "bar"]], ["foo", "bar"]], + 'authorTerms' => [[["foo", "bar"]], ["foo", "bar"]], + 'modifiedRange' => [["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"], ["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"]], + 'markedRange' => [["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"], ["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"]], + 'articleRange' => [[1, 100], [1, 100]], + 'editionRange' => [[1, 100], [1, 100]], + ]; + } + public function testCleanIdArrayValues(): void { $methods = ["articles", "editions", "tags", "labels", "subscriptions"]; $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; From 4a87926dd54c4ba304eb29736627826b01b4acb6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Apr 2022 14:37:28 -0400 Subject: [PATCH 06/36] Fix up context tests --- lib/Context/Context.php | 11 +--- lib/Context/RootMembers.php | 20 ++++++ tests/cases/Misc/TestContext.php | 103 ++++++++++++------------------- 3 files changed, 61 insertions(+), 73 deletions(-) create mode 100644 lib/Context/RootMembers.php diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 592c2f6..e7cdc89 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -7,13 +7,12 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; class Context extends AbstractContext { + use RootMembers; use BooleanMembers; use ExclusionMembers; /** @var ExclusionContext */ public $not; - public $limit = 0; - public $offset = 0; public function __construct() { $this->not = new ExclusionContext($this); @@ -28,12 +27,4 @@ class Context extends AbstractContext { public function __destruct() { unset($this->not); } - - public function limit(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function offset(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Context/RootMembers.php b/lib/Context/RootMembers.php new file mode 100644 index 0000000..d5048b2 --- /dev/null +++ b/lib/Context/RootMembers.php @@ -0,0 +1,20 @@ +act(__FUNCTION__, func_num_args(), $spec); + } + + public function offset(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index fa05348..af778c0 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\ValueInfo; /** @@ -15,76 +16,46 @@ use JKingWeb\Arsse\Misc\ValueInfo; */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { protected $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; + protected $times = ['modifiedRange', 'markedRange']; - public function testVerifyInitialState(): void { - $c = new Context; - foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isStatic() || strpos($m->name, "__") === 0) { - continue; - } - $method = $m->name; - $this->assertFalse($c->$method(), "Context method $method did not initially return false"); - if (in_array($method, $this->ranges)) { - $this->assertEquals([null, null], $c->$method, "Context property $method is not initially a two-member falsy array"); - } else { - $this->assertEquals(null, $c->$method, "Context property $method is not initially falsy"); - } + /** @dataProvider provideContextOptions */ + public function testSetContextOptions(string $method, array $input, $output, bool $not): void { + $parent = new Context; + $c = ($not) ? $parent->not : $parent; + $default = (new \ReflectionProperty($c, $method))->getDefaultValue(); + $this->assertFalse($c->$method(), "Context method did not initially return false"); + if (in_array($method, $this->ranges)) { + $this->assertEquals([null, null], $c->$method, "Context property is not initially a two-member falsy array"); + } else { + $this->assertEquals(null, $c->$method, "Context property is not initially falsy"); } - } - - public function testSetContextOptions(): void { - $v = [ - 'reverse' => true, - 'limit' => 10, - 'offset' => 5, - 'folder' => 42, - 'folders' => [12,22], - 'folderShallow' => 42, - 'foldersShallow' => [0,1], - 'tag' => 44, - 'tags' => [44, 2112], - 'tagName' => "XLIV", - 'tagNames' => ["XLIV", "MMCXII"], - 'subscription' => 2112, - 'subscriptions' => [44, 2112], - 'article' => 255, - 'edition' => 65535, - 'unread' => true, - 'starred' => true, - 'hidden' => true, - 'editions' => [1,2], - 'articles' => [1,2], - 'label' => 2112, - 'labels' => [2112, 1984], - 'labelName' => "Rush", - 'labelNames' => ["Rush", "Orwell"], - 'labelled' => true, - 'annotated' => true, - 'searchTerms' => ["foo", "bar"], - 'annotationTerms' => ["foo", "bar"], - 'titleTerms' => ["foo", "bar"], - 'authorTerms' => ["foo", "bar"], - 'not' => (new Context)->subscription(5), - ]; - $c = new Context; - foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isStatic() || strpos($m->name, "__") === 0 || in_array($m->name, $this->ranges)) { - continue; + $this->assertSame($parent, $c->$method(...$input), "Context method did not return the root after setting"); + $this->assertTrue($c->$method()); + if (in_array($method, $this->times)) { + if (is_array($default)) { + array_walk_recursive($c->$method, function(&$v, $k) { + if ($v !== null) { + $this->assertInstanceOf(\DateTimeImmutable::class, $v, "Context property contains an non-normalized date"); + } + $v = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "iso8601"); + }); + array_walk_recursive($output, function(&$v) { + $v = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "iso8601"); + }); + $this->assertSame($c->$method, $output, "Context property did not return the expected results after setting"); + } else { + $this->assertTime($c->$method, $output, "Context property did not return the expected results after setting"); } - $method = $m->name; - $this->assertArrayHasKey($method, $v, "Context method $method not included in test"); - $this->assertInstanceOf(Context::class, $c->$method($v[$method])); - $this->assertTrue($c->$method()); - $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results"); - // clear the context option - $c->$method(null); - $this->assertFalse($c->$method()); + } else { + $this->assertSame($c->$method, $output, "Context property did not return the expected results after setting"); } + // clear the context option + $c->$method(...array_fill(0, sizeof($input), null)); + $this->assertFalse($c->$method(), "Context method did not return false after clearing"); } public function provideContextOptions(): iterable { - return [ - 'reverse' => [[true], true], + $tests = [ 'limit' => [[10], 10], 'offset' => [[5], 5], 'folder' => [[42], 42], @@ -119,6 +90,12 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'articleRange' => [[1, 100], [1, 100]], 'editionRange' => [[1, 100], [1, 100]], ]; + foreach($tests as $k => $t) { + yield $k => [$k, ...$t, false]; + if (method_exists(ExclusionContext::class, $k)) { + yield "$k (not)" => [$k, ...$t, true]; + } + } } public function testCleanIdArrayValues(): void { From 396ca8648202a8cb3eef86f3a7a154f64034cac5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Apr 2022 23:19:19 -0400 Subject: [PATCH 07/36] Start on removal of conditional CTEs This breaks the code for now, but will make clearer queries once done --- lib/Database.php | 112 ++++++++++++++++++----------------------------- 1 file changed, 43 insertions(+), 69 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 5781f77..7c60a9f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1534,7 +1534,20 @@ class Database { assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist")); // define the basic query, to which we add lots of stuff where necessary $q = new Query( - "SELECT + "WITH RECURSIVE + topmost(f_id,top) as ( + select id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id + ), + folder_data(id,name,top,top_name) as ( + select f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top + ), + labelled(article,label_id,label_name) as ( + select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1 + ), + tagged(subscription,tag_id,tag_name) as ( + select m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1 + ) + select $outColumns from arsse_articles join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ? @@ -1543,16 +1556,14 @@ class Database { left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id join ( - SELECT article, max(id) as edition from arsse_editions group by article + select article, max(id) as edition from arsse_editions group by article ) as latest_editions on arsse_articles.id = latest_editions.article left join ( - SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article + select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article ) as label_stats on label_stats.article = arsse_articles.id", - ["str", "str"], - [$user, $user] + ["str", "str", "str", "str", "str"], + [$user, $user, $user, $user, $user] ); - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]); - $q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top"); $q->setLimit($context->limit, $context->offset); // handle the simple context options $options = [ @@ -1630,76 +1641,39 @@ class Database { } // handle labels and tags $options = [ - 'label' => [ - 'match_col' => "arsse_articles.id", - 'cte_name' => "labelled", - 'cte_cols' => ["article", "label_id", "label_name"], - 'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", - 'cte_types' => ["str"], - 'cte_values' => [$user], - 'options' => [ - 'label' => ['use_name' => false, 'multi' => false], - 'labels' => ['use_name' => false, 'multi' => true], - 'labelName' => ['use_name' => true, 'multi' => false], - 'labelNames' => ['use_name' => true, 'multi' => true], - ], - ], - 'tag' => [ - 'match_col' => "arsse_subscriptions.id", - 'cte_name' => "tagged", - 'cte_cols' => ["subscription", "tag_id", "tag_name"], - 'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1", - 'cte_types' => ["str"], - 'cte_values' => [$user], - 'options' => [ - 'tag' => ['use_name' => false, 'multi' => false], - 'tags' => ['use_name' => false, 'multi' => true], - 'tagName' => ['use_name' => true, 'multi' => false], - 'tagNames' => ['use_name' => true, 'multi' => true], - ], - ], + 'label' => ["labelled", "article", "label_id", "=", "int"], + 'labels' => ["labelled", "article", "label_id", "in", "int"], + 'labelName' => ["labelled", "article", "label_name", "=", "str"], + 'labelNames' => ["labelled", "article", "label_name", "in", "str"], + 'tag' => ["tagged", "subscription", "tag_id", "=", "int"], + 'tags' => ["tagged", "subscription", "tag_id", "in", "int"], + 'tagName' => ["tagged", "subscription", "tag_name", "=", "str"], + 'tagNames' => ["tagged", "subscription", "tag_name", "in", "str"], ]; - foreach ($options as $opt) { - $seen = false; - $match = $opt['match_col']; - $table = $opt['cte_name']; - foreach ($opt['options'] as $m => $props) { - $named = $props['use_name']; - $multi = $props['multi']; - $selection = $opt['cte_cols'][0]; - $col = $opt['cte_cols'][$named ? 2 : 1]; - if ($context->$m()) { - $seen = true; + foreach ($options as $m => [$cte, $col, $selection, $op, $type]) { + if ($context->$m()) { + if ($op === "in") { if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } - if ($multi) { - [$test, $types, $values] = $this->generateIn($context->$m, $named ? "str" : "int"); - $test = "in ($test)"; - } else { - $test = "= ?"; - $types = $named ? "str" : "int"; - $values = $context->$m; - } - $q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values); + [$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); + } else { + $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $$context->$m); } - if ($context->not->$m()) { - $seen = true; - if ($multi) { - [$test, $types, $values] = $this->generateIn($context->not->$m, $named ? "str" : "int"); - $test = "in ($test)"; - } else { - $test = "= ?"; - $types = $named ? "str" : "int"; - $values = $context->not->$m; + } + // handle the exclusionary version + if ($context->not->$m()) { + if ($op === "in") { + if (!$context->not->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } - $q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values); + [$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); + } else { + $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $$context->not->$m); } } - if ($seen) { - $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; - $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); - } } // handle complex context options if ($context->annotated()) { From 97dfef32677b1443eae07b19bee294eae5e9ef08 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Apr 2022 23:30:19 -0400 Subject: [PATCH 08/36] Fix typos --- lib/Database.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 7c60a9f..6f02d0a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1641,10 +1641,10 @@ class Database { } // handle labels and tags $options = [ - 'label' => ["labelled", "article", "label_id", "=", "int"], - 'labels' => ["labelled", "article", "label_id", "in", "int"], - 'labelName' => ["labelled", "article", "label_name", "=", "str"], - 'labelNames' => ["labelled", "article", "label_name", "in", "str"], + 'label' => ["labelled", "id", "label_id", "=", "int"], + 'labels' => ["labelled", "id", "label_id", "in", "int"], + 'labelName' => ["labelled", "id", "label_name", "=", "str"], + 'labelNames' => ["labelled", "id", "label_name", "in", "str"], 'tag' => ["tagged", "subscription", "tag_id", "=", "int"], 'tags' => ["tagged", "subscription", "tag_id", "in", "int"], 'tagName' => ["tagged", "subscription", "tag_name", "=", "str"], @@ -1659,7 +1659,7 @@ class Database { [$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); } else { - $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $$context->$m); + $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $context->$m); } } // handle the exclusionary version @@ -1671,7 +1671,7 @@ class Database { [$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type); $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); } else { - $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $$context->not->$m); + $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $context->not->$m); } } } From 53ba591720aaef61da21d6c45352a62d78b40345 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Apr 2022 19:22:50 -0400 Subject: [PATCH 09/36] Finish up article selection refactor --- lib/Context/ExclusionMembers.php | 26 +++++----- lib/Database.php | 87 +++++++++++++++++--------------- tests/cases/Misc/TestContext.php | 2 +- 3 files changed, 59 insertions(+), 56 deletions(-) diff --git a/lib/Context/ExclusionMembers.php b/lib/Context/ExclusionMembers.php index d9d82c7..05c2614 100644 --- a/lib/Context/ExclusionMembers.php +++ b/lib/Context/ExclusionMembers.php @@ -11,27 +11,27 @@ use JKingWeb\Arsse\Misc\Date; trait ExclusionMembers { public $folder = null; - public $folders = null; + public $folders = []; public $folderShallow = null; - public $foldersShallow = null; + public $foldersShallow = []; public $tag = null; - public $tags = null; + public $tags = []; public $tagName = null; - public $tagNames = null; + public $tagNames = []; public $subscription = null; - public $subscriptions = null; + public $subscriptions = []; public $edition = null; - public $editions = null; + public $editions = []; public $article = null; - public $articles = null; + public $articles = []; public $label = null; - public $labels = null; + public $labels = []; public $labelName = null; - public $labelNames = null; - public $annotationTerms = null; - public $searchTerms = null; - public $titleTerms = null; - public $authorTerms = null; + public $labelNames = []; + public $annotationTerms = []; + public $searchTerms = []; + public $titleTerms = []; + public $authorTerms = []; public $articleRange = [null, null]; public $editionRange = [null, null]; public $modifiedRange = [null, null]; diff --git a/lib/Database.php b/lib/Database.php index 6f02d0a..cec47f9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1533,6 +1533,9 @@ class Database { } assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist")); // define the basic query, to which we add lots of stuff where necessary + // selecting from folders requires in() clauses, which may be empty + [$fmInClause, $fmInTypes, $fmInValues] = $this->generateIn($context->folders, "int"); + [$fmxInClause, $fmxInTypes, $fmxInValues] = $this->generateIn($context->not->folders, "int"); $q = new Query( "WITH RECURSIVE topmost(f_id,top) as ( @@ -1541,6 +1544,18 @@ class Database { folder_data(id,name,top,top_name) as ( select f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top ), + folders(folder) as ( + select ? union all select id from arsse_folders join folders on coalesce(parent,0) = folder + ), + folders_multi(folder) as ( + select id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($fmInClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder + ), + folders_excluded(folder) as ( + select ? union all select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder + ), + folders_multi_excluded(folder) as ( + select id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($fmxInClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder + ), labelled(article,label_id,label_name) as ( select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1 ), @@ -1561,8 +1576,8 @@ class Database { left join ( select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article ) as label_stats on label_stats.article = arsse_articles.id", - ["str", "str", "str", "str", "str"], - [$user, $user, $user, $user, $user] + ["str", "int", "str", $fmInTypes, "int", "str", $fmxInTypes, "str", "str", "str", "str"], + [$user, $context->folder, $user, $fmInValues, $context->not->folder, $user, $fmxInValues, $user, $user, $user, $user] ); $q->setLimit($context->limit, $context->offset); // handle the simple context options @@ -1641,25 +1656,26 @@ class Database { } // handle labels and tags $options = [ - 'label' => ["labelled", "id", "label_id", "=", "int"], - 'labels' => ["labelled", "id", "label_id", "in", "int"], - 'labelName' => ["labelled", "id", "label_name", "=", "str"], - 'labelNames' => ["labelled", "id", "label_name", "in", "str"], - 'tag' => ["tagged", "subscription", "tag_id", "=", "int"], - 'tags' => ["tagged", "subscription", "tag_id", "in", "int"], - 'tagName' => ["tagged", "subscription", "tag_name", "=", "str"], - 'tagNames' => ["tagged", "subscription", "tag_name", "in", "str"], + // each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE + 'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"], + 'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"], + 'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"], + 'labelNames' => ["labelled", "id", "labelled.article", "label_name", "in", "str"], + 'tag' => ["tagged", "subscription", "tagged.subscription", "tag_id", "=", "int"], + 'tags' => ["tagged", "subscription", "tagged.subscription", "tag_id", "in", "int"], + 'tagName' => ["tagged", "subscription", "tagged.subscription", "tag_name", "=", "str"], + 'tagNames' => ["tagged", "subscription", "tagged.subscription", "tag_name", "in", "str"], ]; - foreach ($options as $m => [$cte, $col, $selection, $op, $type]) { + foreach ($options as $m => [$cte, $outerCol, $selection, $innerCol, $op, $type]) { if ($context->$m()) { if ($op === "in") { if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } [$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type); - $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); + $q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues); } else { - $q->setWhere("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $context->$m); + $q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->$m); } } // handle the exclusionary version @@ -1669,13 +1685,26 @@ class Database { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } [$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type); - $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col in($inClause))", $inTypes, $inValues); + $q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues); } else { - $q->setWhereNot("{$colDefs[$col]} in (select $selection from $cte where $col = ?)", $type, $context->not->$m); + $q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->not->$m); } } } - // handle complex context options + // handle folder selection + $options = [ + 'folder' => "folders", + 'folders' => "folders_multi", + ]; + foreach ($options as $m => $cte) { + if ($context->$m()) { + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from $cte)"); + } + if ($context->not->$m()) { + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from {$cte}_excluded)"); + } + } + // handle context options with more than one operator if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); @@ -1685,32 +1714,6 @@ class Database { $op = $context->labelled ? ">" : "="; $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } - 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 all select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder); - // limit subscriptions to the listed folders - $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); - } - if ($context->folders()) { - [$inClause, $inTypes, $inValues] = $this->generateIn($context->folders, "int"); - // add a common table expression to list the folders and their children so that we select from the entire subtree - $q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); - // limit subscriptions to the listed folders - $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)"); - } - if ($context->not->folder()) { - // add a common table expression to list the folder and its children so that we exclude from the entire subtree - $q->setCTE("folders_excluded(folder)", "SELECT ? union all select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); - // excluded any subscriptions in the listed folders - $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); - } - if ($context->not->folders()) { - [$inClause, $inTypes, $inValues] = $this->generateIn($context->not->folders, "int"); - // add a common table expression to list the folders and their children so that we select from the entire subtree - $q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); - // limit subscriptions to the listed folders - $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)"); - } // handle text-matching context options $options = [ "titleTerms" => ["title"], diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index af778c0..93e343d 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -27,7 +27,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { if (in_array($method, $this->ranges)) { $this->assertEquals([null, null], $c->$method, "Context property is not initially a two-member falsy array"); } else { - $this->assertEquals(null, $c->$method, "Context property is not initially falsy"); + $this->assertFalse((bool) $c->$method, "Context property is not initially falsy"); } $this->assertSame($parent, $c->$method(...$input), "Context method did not return the root after setting"); $this->assertTrue($c->$method()); From 427bddd3b7e387422bd5c021cca931f099dc0b74 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Apr 2022 20:09:07 -0400 Subject: [PATCH 10/36] Allow multiple date ranges --- lib/Context/ExclusionMembers.php | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/Context/ExclusionMembers.php b/lib/Context/ExclusionMembers.php index 05c2614..1efa53a 100644 --- a/lib/Context/ExclusionMembers.php +++ b/lib/Context/ExclusionMembers.php @@ -35,7 +35,9 @@ trait ExclusionMembers { public $articleRange = [null, null]; public $editionRange = [null, null]; public $modifiedRange = [null, null]; + public $modifiedRanges = []; public $markedRange = [null, null]; + public $markedRanges = []; protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); @@ -64,6 +66,22 @@ trait ExclusionMembers { return array_values(array_unique($spec)); } + protected function cleanDateRangeArray(array $spec): array { + $spec = array_values($spec); + $stop = sizeof($spec); + for ($a = 0; $a < $stop; $a++) { + if (!is_array($spec[$a]) || sizeof($spec[$a]) !== 2) { + unset($spec[$a]); + } else { + $spec[$a] = ValueInfo::normalize($spec[$a], ValueInfo::T_DATE | ValueInfo::M_ARRAY | ValueInfo::M_DROP); + if ($spec[$a] === [null, null]) { + unset($spec[$a]); + } + } + } + return array_values(array_unique($spec)); + } + public function folder(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -218,6 +236,14 @@ trait ExclusionMembers { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function modifiedRanges(array $spec) { + if (isset($spec)) { + $spec = $this->cleanDateRangeArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedRange($start = null, $end = null) { if ($start === null && $end === null) { $spec = null; @@ -226,4 +252,11 @@ trait ExclusionMembers { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function markedRanges(array $spec) { + if (isset($spec)) { + $spec = $this->cleanDateRangeArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } From fe0261321485df759954f69f78b1a5e18e1ff353 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Apr 2022 22:46:13 -0400 Subject: [PATCH 11/36] Fix coverage --- lib/Database.php | 3 ++- tests/cases/Database/SeriesArticle.php | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index cec47f9..40e0e34 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1682,7 +1682,8 @@ class Database { if ($context->not->$m()) { if ($op === "in") { if (!$context->not->$m) { - throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + // for exclusions we don't care if the array is empty + continue; } [$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type); $q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 2a4aa51..b7d1eb0 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -505,6 +505,8 @@ trait SeriesArticle { '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]], + 'Folder tree 1 excluding no labels' => [(new Context)->folder(1)->not->labels([]), [5,6,7,8]], + 'Folder tree 1 excluding no tags' => [(new Context)->folder(1)->not->tags([]), [5,6,7,8]], 'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59")->not->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [1,3,5,7,8]], 'Search with exclusion' => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], From 895c045c9b66988a409e1dab9b9bb6ec73220c17 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Apr 2022 11:15:57 -0400 Subject: [PATCH 12/36] Simplify folder selection in article queries --- lib/Database.php | 44 +++++++++++--------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 40e0e34..1e0950a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1533,28 +1533,17 @@ class Database { } assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist")); // define the basic query, to which we add lots of stuff where necessary - // selecting from folders requires in() clauses, which may be empty - [$fmInClause, $fmInTypes, $fmInValues] = $this->generateIn($context->folders, "int"); - [$fmxInClause, $fmxInTypes, $fmxInValues] = $this->generateIn($context->not->folders, "int"); + [$fInClause, $fInTypes, $fInValues] = $this->generateIn([...$context->folders, ...$context->not->folders, $context->folder, $context->not->folder], "int"); $q = new Query( "WITH RECURSIVE - topmost(f_id,top) as ( - select id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id + folders_top(id,top) as ( + select f.id,f.id from arsse_folders as f where owner = ? and parent is null union all select f.id,top from arsse_folders as f join folders_top as t on parent=t.id ), folder_data(id,name,top,top_name) as ( - select f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top + select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top ), - folders(folder) as ( - select ? union all select id from arsse_folders join folders on coalesce(parent,0) = folder - ), - folders_multi(folder) as ( - select id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($fmInClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder - ), - folders_excluded(folder) as ( - select ? union all select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder - ), - folders_multi_excluded(folder) as ( - select id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($fmxInClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder + folders(id,req) as ( + select * from (select 0,0 union select f.id,f.id from arsse_folders as f where owner = ? and id in ($fInClause)) union all select f.id,req from arsse_folders as f join folders on coalesce(parent,0)=folders.id ), labelled(article,label_id,label_name) as ( select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1 @@ -1576,8 +1565,8 @@ class Database { left join ( select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article ) as label_stats on label_stats.article = arsse_articles.id", - ["str", "int", "str", $fmInTypes, "int", "str", $fmxInTypes, "str", "str", "str", "str"], - [$user, $context->folder, $user, $fmInValues, $context->not->folder, $user, $fmxInValues, $user, $user, $user, $user] + ["str", "str", $fInTypes, "str", "str", "str", "str"], + [$user, $user, $fInValues, $user, $user, $user, $user] ); $q->setLimit($context->limit, $context->offset); // handle the simple context options @@ -1654,9 +1643,11 @@ class Database { $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } - // handle labels and tags + // handle folders, labels, and tags $options = [ // each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE + 'folder' => ["folders", "folder", "folders.id", "req", "=", "int"], + 'folders' => ["folders", "folder", "folders.id", "req", "in", "int"], 'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"], 'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"], 'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"], @@ -1692,19 +1683,6 @@ class Database { } } } - // handle folder selection - $options = [ - 'folder' => "folders", - 'folders' => "folders_multi", - ]; - foreach ($options as $m => $cte) { - if ($context->$m()) { - $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from $cte)"); - } - if ($context->not->$m()) { - $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from {$cte}_excluded)"); - } - } // handle context options with more than one operator if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; From 0bd01849bb76ba30a22b7c76df7c0023c2f8ed8c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Apr 2022 11:51:53 -0400 Subject: [PATCH 13/36] Remove unnecessary in() clause --- lib/Database.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 1e0950a..ca6696f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1533,18 +1533,17 @@ class Database { } assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist")); // define the basic query, to which we add lots of stuff where necessary - [$fInClause, $fInTypes, $fInValues] = $this->generateIn([...$context->folders, ...$context->not->folders, $context->folder, $context->not->folder], "int"); $q = new Query( "WITH RECURSIVE + folders(id,req) as ( + select * from (select 0,0 union select f.id,f.id from arsse_folders as f where owner = ?) union all select f.id,req from arsse_folders as f join folders on coalesce(parent,0)=folders.id + ), folders_top(id,top) as ( select f.id,f.id from arsse_folders as f where owner = ? and parent is null union all select f.id,top from arsse_folders as f join folders_top as t on parent=t.id ), folder_data(id,name,top,top_name) as ( select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top ), - folders(id,req) as ( - select * from (select 0,0 union select f.id,f.id from arsse_folders as f where owner = ? and id in ($fInClause)) union all select f.id,req from arsse_folders as f join folders on coalesce(parent,0)=folders.id - ), labelled(article,label_id,label_name) as ( select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1 ), @@ -1565,8 +1564,8 @@ class Database { left join ( select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article ) as label_stats on label_stats.article = arsse_articles.id", - ["str", "str", $fInTypes, "str", "str", "str", "str"], - [$user, $user, $fInValues, $user, $user, $user, $user] + ["str", "str", "str", "str", "str", "str"], + [$user, $user, $user, $user, $user, $user] ); $q->setLimit($context->limit, $context->offset); // handle the simple context options @@ -1647,7 +1646,7 @@ class Database { $options = [ // each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE 'folder' => ["folders", "folder", "folders.id", "req", "=", "int"], - 'folders' => ["folders", "folder", "folders.id", "req", "in", "int"], + 'folders' => ["folders", "folder", "folders.id", "req", "in", "int"], 'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"], 'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"], 'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"], From 2489743d0fe23ef9a6cc42f16a40d4f90da0ec6d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Apr 2022 13:21:52 -0400 Subject: [PATCH 14/36] Further simplifications --- lib/Database.php | 19 +++++++------------ lib/Db/SQLite3/Driver.php | 2 ++ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ca6696f..088262a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1449,6 +1449,7 @@ class Database { */ protected function articleColumns(): array { $greatest = $this->db->sqlToken("greatest"); + $least = $this->db->sqlToken("least"); return [ 'id' => "arsse_articles.id", // The article's unchanging numeric ID 'edition' => "latest_editions.edition", // The article's numeric ID which increases each time it is modified in the feed @@ -1468,6 +1469,8 @@ class Database { 'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden 'starred' => "coalesce(arsse_marks.starred,0)", // Whether the article is starred 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", // Whether the article is unread + 'labelled' => "$least(coalesce(label_stats.assigned,0),1)", // Whether the article has at least one label + 'annotated' => "(case when coalesce(arsse_marks.note,'') <> '' then 1 else 0 end)", // Whether the article has a note 'note' => "coalesce(arsse_marks.note,'')", // The article's note, if any 'published_date' => "arsse_articles.published", // The date at which the article was first published i.e. its creation date 'edited_date' => "arsse_articles.edited", // The date at which the article was last edited according to the feed @@ -1536,10 +1539,10 @@ class Database { $q = new Query( "WITH RECURSIVE folders(id,req) as ( - select * from (select 0,0 union select f.id,f.id from arsse_folders as f where owner = ?) union all select f.id,req from arsse_folders as f join folders on coalesce(parent,0)=folders.id + select 0, 0 union all select f.id, f.id from arsse_folders as f where owner = ? union all select f1.id, req from arsse_folders as f1 join folders on coalesce(parent,0)=folders.id ), folders_top(id,top) as ( - select f.id,f.id from arsse_folders as f where owner = ? and parent is null union all select f.id,top from arsse_folders as f join folders_top as t on parent=t.id + select f.id, f.id from arsse_folders as f where owner = ? and parent is null union all select f.id, top from arsse_folders as f join folders_top as t on parent=t.id ), folder_data(id,name,top,top_name) as ( select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top @@ -1586,6 +1589,8 @@ class Database { "unread" => ["unread", "=", "bool"], "starred" => ["starred", "=", "bool"], "hidden" => ["hidden", "=", "bool"], + "labelled" => ["labelled", "=", "bool"], + "annotated" => ["annotated", "=", "bool"], ]; foreach ($options as $m => [$col, $op, $type]) { if (!$context->$m()) { @@ -1682,16 +1687,6 @@ class Database { } } } - // handle context options with more than one operator - if ($context->annotated()) { - $comp = ($context->annotated) ? "<>" : "="; - $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); - } - if ($context->labelled()) { - // any label (true) or no label (false) - $op = $context->labelled ? ">" : "="; - $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); - } // handle text-matching context options $options = [ "titleTerms" => ["title"], diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 7c5a110..b4f9129 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { switch (strtolower($token)) { case "greatest": return "max"; + case "least": + return "min"; case "asc": return ""; default: From 33a3478a58a3484db10e895484b90c8d6617814d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Apr 2022 17:24:25 -0400 Subject: [PATCH 15/36] Avoid use of PHP 7.4 feature --- tests/cases/Misc/TestContext.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 93e343d..78bc11e 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -91,9 +91,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'editionRange' => [[1, 100], [1, 100]], ]; foreach($tests as $k => $t) { - yield $k => [$k, ...$t, false]; + yield $k => array_merge([$k], $t, [false]); if (method_exists(ExclusionContext::class, $k)) { - yield "$k (not)" => [$k, ...$t, true]; + yield "$k (not)" => array_merge([$k], $t, [true]); } } } From f6799e2ab1d2b619bbcb7d1e709f44c56e4e8818 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Apr 2022 12:25:37 -0400 Subject: [PATCH 16/36] Tests for date ranges in contexts --- lib/Context/ExclusionMembers.php | 2 +- lib/Misc/ValueInfo.php | 10 +- tests/cases/Db/BaseDriver.php | 2 + tests/cases/Misc/TestContext.php | 11 ++ tests/cases/Misc/TestValueInfo.php | 166 ++++++++++++++--------------- 5 files changed, 104 insertions(+), 87 deletions(-) diff --git a/lib/Context/ExclusionMembers.php b/lib/Context/ExclusionMembers.php index 1efa53a..62f2a40 100644 --- a/lib/Context/ExclusionMembers.php +++ b/lib/Context/ExclusionMembers.php @@ -79,7 +79,7 @@ trait ExclusionMembers { } } } - return array_values(array_unique($spec)); + return array_values(array_unique($spec, \SORT_REGULAR)); } public function folder(int $spec = null) { diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 0aba770..8b31590 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -281,15 +281,19 @@ class ValueInfo { if (!$out) { throw new \Exception; } - return $out; + return $out->setTimezone(new \DateTimeZone("UTC")); } else { - return new \DateTimeImmutable($value, new \DateTimeZone("UTC")); + $out = new \DateTimeImmutable($value, new \DateTimeZone("UTC")); + if ($out) { + return $out->setTimezone(new \DateTimeZone("UTC")); + } elseif ($strict && !$drop) { + throw new \Exception; + } } } catch (\Exception $e) { if ($strict && !$drop) { throw new ExceptionType("strictFailure", $type); } - return null; } } elseif ($strict && !$drop) { throw new ExceptionType("strictFailure", $type); diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 89a2600..fe7f344 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -387,9 +387,11 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $integer = $this->drv->sqlToken("InTEGer"); $asc = $this->drv->sqlToken("asc"); $desc = $this->drv->sqlToken("desc"); + $least = $this->drv->sqlToken("leASt"); $this->assertSame("NOT_A_TOKEN", $this->drv->sqlToken("NOT_A_TOKEN")); + $this->assertSame("A", $this->drv->query("SELECT $least('Z', 'A')")->getValue()); $this->assertSame("Z", $this->drv->query("SELECT $greatest('Z', 'A')")->getValue()); $this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue()); $this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue()); diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 78bc11e..1f8b638 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Context\ExclusionContext; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** @@ -129,6 +130,16 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } + public function testCleanDateRangeArrayValues(): void { + $methods = ["modifiedRanges", "markedRanges"]; + $in = [null, 1, [1, 2, 3], [1], [null, null], ["ook", null], ["2022-09-13T06:46:28 America/Los_angeles", new \DateTime("2022-01-23T00:33:49Z")], [0, null], [null, 0]]; + $out = [[Date::normalize("2022-09-13T13:46:28Z"), Date::normalize("2022-01-23T00:33:49Z")], [Date::normalize(0), null], [null, Date::normalize(0)]]; + $c = new Context; + foreach ($methods as $method) { + $this->assertEquals($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + public function testCloneAContext(): void { $c1 = new Context; $c2 = clone $c1; diff --git a/tests/cases/Misc/TestValueInfo.php b/tests/cases/Misc/TestValueInfo.php index 7b30e11..e17be63 100644 --- a/tests/cases/Misc/TestValueInfo.php +++ b/tests/cases/Misc/TestValueInfo.php @@ -349,7 +349,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { // if we're performing a strict comparison and the value is supposed to fail, we should be getting an exception $this->assertException("strictFailure", "", "ExceptionType"); I::normalize($input, $typeConst | $modeConst); - $this->assertTrue(false, "$typename $modeName test expected exception"); + $this->assertTrue(false, "$typeName $modeName test expected exception"); } elseif ($drop && !$pass) { // if we're performing a drop comparison and the value is supposed to fail, change the expectation to null $exp = null; @@ -451,88 +451,88 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { For each of these types, there is an expected output value, as well as a boolean indicating whether the value should pass or fail a strict normalization. Conversion to DateTime is covered below by a different data set */ - /* Input value null bool int float string array interval */ - [null, [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], false], [null, false]], - ["", [null,true], [false,true], [0, false], [0.0, false], ["", true], [[""], false], [null, false]], - [1, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1], false], [$this->i("PT1S"), false]], - [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX,true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false], [$this->i("P292471208677Y195DT15H30M7S"), false]], - [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false], [$this->i("PT1S"), false]], - ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false], [null, false]], - ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false], [null, false]], - ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false], [null, false]], - ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false], [null, false]], - ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false], [null, false]], - ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false], [null, false]], - ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false], [null, false]], - ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false], [null, false]], - ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false], [null, false]], - ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false], [null, false]], - ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false], [null, false]], - ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false], [null, false]], - [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false], [$this->i("PT0S"), false]], - ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false], [null, false]], - ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false], [null, false]], - [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false], [$this->i("PT0S"), false]], - ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false], [null, false]], - ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false], [null, false]], - ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false], [null, false]], - ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false], [null, false]], - ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false], [null, false]], - ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false], [null, false]], - [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false], [$this->i("PT1S"), false]], - [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false], [$this->i("PT1S"), false]], - ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false], [null, false]], - ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false], [null, false]], - ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false], [null, false]], - ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false], [null, false]], - ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false], [null, false]], - ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false], [null, false]], - [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false], [$this->i("PT0S"), false]], - ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false], [null, false]], - ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false], [null, false]], - [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false], [$this->i("PT0S"), false]], - ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false], [null, false]], - ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false], [null, false]], - [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false], [null, false]], - [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false], [null, false]], - ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false], [null, false]], - ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false], [null, false]], - ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false], [null, false]], - ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false], [null, false]], - ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false], [null, false]], - ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false], [null, false]], - [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false], [null, false]], - [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false], [null, false]], - [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false], [null, false]], - [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true], [null, false]], - ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false], [null, false]], - [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false], [null, false]], - [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false], [null, false]], - [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false], [null, false]], - [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false], [null, false]], - [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false], [null, false]], - [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false], [null, false]], - [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false], [null, false]], - [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false], [null, false]], - [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false], [$this->i("PT2S", 0.5), false]], - [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false], [$this->i("PT0S", 0.5), false]], - ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false], [null, false]], - ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false], [null, false]], - [$this->d("2010-01-01T00:00:00", 0, 0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 0)],false], [null, false]], - [$this->d("2010-01-01T00:00:00", 0, 1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 1)],false], [null, false]], - [$this->d("2010-01-01T00:00:00", 1, 0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 0)],false], [null, false]], - [$this->d("2010-01-01T00:00:00", 1, 1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 1)],false], [null, false]], - [1e14, [null,true], [true, false], [10 ** 14, true], [1e14, true], ["100000000000000", true], [[1e14], false], [$this->i("P1157407407DT9H46M40S"), false]], - [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false], [$this->i("PT0S", 1e-6), false]], - [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true], [null, false]], - [['a' => 1,'b' => 2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a' => 1,'b' => 2], true], [null, false]], - [new Result([['a' => 1,'b' => 2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a' => 1,'b' => 2]], true], [null, false]], - [$this->i("PT1H"), [null,true], [true, false], [60 * 60, false], [60.0 * 60.0, false], ["PT1H", true], [[$this->i("PT1H")], false], [$this->i("PT1H"), true]], - [$this->i("P2DT1H"), [null,true], [true, false], [(48 + 1) * 60 * 60, false], [1.0 * (48 + 1) * 60 * 60, false], ["P2DT1H", true], [[$this->i("P2DT1H")], false], [$this->i("P2DT1H"), true]], - [$this->i("PT0H"), [null,true], [true, false], [0, false], [0.0, false], ["PT0S", true], [[$this->i("PT0H")], false], [$this->i("PT0H"), true]], - [$dateDiff, [null,true], [true, false], [366 * 24 * 60 * 60, false], [1.0 * 366 * 24 * 60 * 60, false], ["P366D", true], [[$dateDiff], false], [$dateNorm, true]], - ["1 year, 2 days", [null,true], [true, false], [0, false], [0.0, false], ["1 year, 2 days", true], [["1 year, 2 days"], false], [$this->i("P1Y2D"), false]], - ["P1Y2D", [null,true], [true, false], [0, false], [0.0, false], ["P1Y2D", true], [["P1Y2D"], false], [$this->i("P1Y2D"), true]], + /* Input value null bool int float string array interval */ + [null, [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], false], [null, false]], + ["", [null,true], [false,true], [0, false], [0.0, false], ["", true], [[""], false], [null, false]], + [1, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1], false], [$this->i("PT1S"), false]], + [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX, true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false], [$this->i("P292471208677Y195DT15H30M7S"), false]], + [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false], [$this->i("PT1S"), false]], + ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false], [null, false]], + ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false], [null, false]], + ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false], [null, false]], + ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false], [null, false]], + ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false], [null, false]], + ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false], [null, false]], + ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false], [null, false]], + ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false], [null, false]], + ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false], [null, false]], + ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false], [null, false]], + ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false], [null, false]], + ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false], [null, false]], + [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false], [$this->i("PT0S"), false]], + ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false], [null, false]], + ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false], [null, false]], + [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false], [$this->i("PT0S"), false]], + ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false], [null, false]], + ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false], [null, false]], + ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false], [null, false]], + ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false], [null, false]], + ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false], [null, false]], + ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false], [null, false]], + [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false], [$this->i("PT1S"), false]], + [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false], [$this->i("PT1S"), false]], + ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false], [null, false]], + ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false], [null, false]], + ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false], [null, false]], + ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false], [null, false]], + ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false], [null, false]], + ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false], [null, false]], + [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false], [$this->i("PT0S"), false]], + ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false], [null, false]], + ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false], [null, false]], + [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false], [$this->i("PT0S"), false]], + ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false], [null, false]], + ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false], [null, false]], + [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false], [null, false]], + [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false], [null, false]], + ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false], [null, false]], + ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false], [null, false]], + ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false], [null, false]], + ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false], [null, false]], + ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false], [null, false]], + ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false], [null, false]], + [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false], [null, false]], + [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false], [null, false]], + [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false], [null, false]], + [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true], [null, false]], + ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false], [null, false]], + [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false], [null, false]], + [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false], [null, false]], + [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false], [null, false]], + [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false], [null, false]], + [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false], [null, false]], + [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false], [null, false]], + [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false], [null, false]], + [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false], [null, false]], + [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false], [$this->i("PT2S", 0.5), false]], + [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false], [$this->i("PT0S", 0.5), false]], + ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false], [null, false]], + ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false], [null, false]], + [$this->d("2010-01-01T00:00:00", 0, 0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 0)],false], [null, false]], + [$this->d("2010-01-01T00:00:00", 0, 1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 1)],false], [null, false]], + [$this->d("2010-01-01T00:00:00", 1, 0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 0)],false], [null, false]], + [$this->d("2010-01-01T00:00:00", 1, 1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 1)],false], [null, false]], + [1e14, [null,true], [true, false], [10 ** 14, true], [1e14, true], ["100000000000000", true], [[1e14], false], [$this->i("P1157407407DT9H46M40S"), false]], + [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false], [$this->i("PT0S", 1e-6), false]], + [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true], [null, false]], + [['a' => 1,'b' => 2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a' => 1,'b' => 2], true], [null, false]], + [new Result([['a' => 1,'b' => 2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a' => 1,'b' => 2]], true], [null, false]], + [$this->i("PT1H"), [null,true], [true, false], [60 * 60, false], [60.0 * 60.0, false], ["PT1H", true], [[$this->i("PT1H")], false], [$this->i("PT1H"), true]], + [$this->i("P2DT1H"), [null,true], [true, false], [(48 + 1) * 60 * 60, false], [1.0 * (48 + 1) * 60 * 60, false], ["P2DT1H", true], [[$this->i("P2DT1H")], false], [$this->i("P2DT1H"), true]], + [$this->i("PT0H"), [null,true], [true, false], [0, false], [0.0, false], ["PT0S", true], [[$this->i("PT0H")], false], [$this->i("PT0H"), true]], + [$dateDiff, [null,true], [true, false], [366 * 24 * 60 * 60, false], [1.0 * 366 * 24 * 60 * 60, false], ["P366D", true], [[$dateDiff], false], [$dateNorm, true]], + ["1 year, 2 days", [null,true], [true, false], [0, false], [0.0, false], ["1 year, 2 days", true], [["1 year, 2 days"], false], [$this->i("P1Y2D"), false]], + ["P1Y2D", [null,true], [true, false], [0, false], [0.0, false], ["P1Y2D", true], [["P1Y2D"], false], [$this->i("P1Y2D"), true]], ] as $set) { // shift the input value off the set $input = array_shift($set); From 2acacd264775616d1f6d8aa4f900e9460bbe0fca Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Apr 2022 20:13:08 -0400 Subject: [PATCH 17/36] Implement handling for arrays of ranges Multiple ranges of articles or editions were not implemented, but the functionality is generic and could be extended if later needed. --- lib/Context/ExclusionMembers.php | 4 +- lib/Database.php | 164 +++++++++++++++++++------------ 2 files changed, 103 insertions(+), 65 deletions(-) diff --git a/lib/Context/ExclusionMembers.php b/lib/Context/ExclusionMembers.php index 62f2a40..b326f6d 100644 --- a/lib/Context/ExclusionMembers.php +++ b/lib/Context/ExclusionMembers.php @@ -236,7 +236,7 @@ trait ExclusionMembers { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function modifiedRanges(array $spec) { + public function modifiedRanges(array $spec = null) { if (isset($spec)) { $spec = $this->cleanDateRangeArray($spec); } @@ -253,7 +253,7 @@ trait ExclusionMembers { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function markedRanges(array $spec) { + public function markedRanges(array $spec = null) { if (isset($spec)) { $spec = $this->cleanDateRangeArray($spec); } diff --git a/lib/Database.php b/lib/Database.php index 088262a..3608e21 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1593,61 +1593,57 @@ class Database { "annotated" => ["annotated", "=", "bool"], ]; foreach ($options as $m => [$col, $op, $type]) { - if (!$context->$m()) { - // context is not being used - continue; - } elseif ($op === "between") { - // option is a range - if ($context->$m[0] === null) { - // range is open at the low end - $q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]); - } elseif ($context->$m[1] === null) { - // range is open at the high end - $q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]); + if ($context->$m()) { + if ($op === "between") { + // option is a range + if ($context->$m[0] === null) { + // range is open at the low end + $q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]); + } elseif ($context->$m[1] === null) { + // range is open at the high end + $q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]); + } else { + // range is bounded in both directions + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m); + } + } 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 + } + [$clause, $types, $values] = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); } else { - // range is bounded in both directions - $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m); + $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } - } 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 - } - [$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); } - } - // further handle exclusionary options if specified - foreach ($options as $m => [$col, $op, $type]) { - if (!method_exists($context->not, $m) || !$context->not->$m()) { - // context option is not being used - continue; - } elseif ($op === "between") { - // option is a range - if ($context->not->$m[0] === null) { - // range is open at the low end - $q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]); - } elseif ($context->not->$m[1] === null) { - // range is open at the high end - $q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]); + // handle the exclusionary version + if (method_exists($context->not, $m) && $context->not->$m()) { + if ($op === "between") { + // option is a range + if ($context->not->$m[0] === null) { + // range is open at the low end + $q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]); + } elseif ($context->not->$m[1] === null) { + // range is open at the high end + $q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]); + } else { + // range is bounded in both directions + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m); + } + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty + continue; + } + [$clause, $types, $values] = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); } else { - // range is bounded in both directions - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m); + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } - } elseif (is_array($context->not->$m)) { - if (!$context->not->$m) { - // for exclusions we don't care if the array is empty - continue; - } - [$clause, $types, $values] = $this->generateIn($context->not->$m, $type); - $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); - } else { - $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } - // handle folders, labels, and tags + // handle folder trees, labels, and tags $options = [ // each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE 'folder' => ["folders", "folder", "folders.id", "req", "=", "int"], @@ -1695,27 +1691,69 @@ class Database { "annotationTerms" => ["note"], ]; foreach ($options as $m => $columns) { - 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 - } $columns = array_map(function($c) use ($colDefs) { assert(isset($colDefs[$c]), new Exception("constantUnknown", $c)); return $colDefs[$c]; }, $columns); - $q->setWhere(...$this->generateSearch($context->$m, $columns)); + if ($context->$m()) { + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } + $q->setWhere(...$this->generateSearch($context->$m, $columns)); + } + // handle the exclusionary version + if ($context->not->$m() && $context->not->$m) { + $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); + } } - // further handle exclusionary text-matching context options - foreach ($options as $m => $columns) { - if (!$context->not->$m() || !$context->not->$m) { - continue; + // handle arrays of ranges + $options = [ + 'modifiedRanges' => ["modified_date", "datetime"], + 'markedRanges' => ["marked_date", "datetime"], + ]; + foreach ($options as $m => [$col, $type]) { + if ($context->$m()) { + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } + $w = []; + $t = []; + $v = []; + foreach ($context->$m as $r) { + if ($r[0] === null) { + // range is open at the low end + $w[] = "{$colDefs[$col]} <= ?"; + $t[] = $type; + $v[] = $r[1]; + } elseif ($context->$m[1] === null) { + // range is open at the high end + $w[] = "{$colDefs[$col]} >= ?"; + $t[] = $type; + $v[] = $r[0]; + } else { + // range is bounded in both directions + $w[] = "{$colDefs[$col]} BETWEEN ? AND ?"; + $t[] = [$type, $type]; + $v[] = $r; + } + } + $q->setWhere("(".implode(" OR ", $w).")", $t, $v); + } + // handle the exclusionary version + if ($context->not->$m() && $context->not->$m) { + foreach ($context->not->$m as $r) { + if ($r[0] === null) { + // range is open at the low end + $q->setWhereNot("{$colDefs[$col]} <= ?", $type, $r[1]); + } elseif ($r[1] === null) { + // range is open at the high end + $q->setWhereNot("{$colDefs[$col]} >= ?", $type, $r[0]); + } else { + // range is bounded in both directions + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r); + } + } } - $columns = array_map(function($c) use ($colDefs) { - assert(isset($colDefs[$c]), new Exception("constantUnknown", $c)); - return $colDefs[$c]; - }, $columns); - $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); } // return the query return $q; From e6505a5fdaaeb1b9cd3f53f4c7ac0bbf6126a64f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Apr 2022 09:56:13 -0400 Subject: [PATCH 18/36] Work around possible MySQL bug --- CHANGELOG | 6 ++++++ lib/Db/MySQL/Driver.php | 2 +- lib/Misc/ValueInfo.php | 8 ++------ tests/cases/Misc/TestValueInfo.php | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fa856e1..67c3cab 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +Version 0.1?.? (2022-??-??) +=========================== + +Bug fixes: +- Perform MySQL table maintenance more reliably + Version 0.10.2 (2022-04-04) =========================== diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 9aca818..c61762a 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -224,7 +224,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function maintenance(): bool { // with MySQL each table must be analyzed separately, so we first have to get a list of tables - foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) { + foreach ($this->query("SHOW TABLES like 'arsse%'") as $table) { $table = array_pop($table); if (!preg_match("/^arsse_[a-z_]+$/D", $table)) { // table is not one of ours diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 8b31590..d03949c 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -283,12 +283,8 @@ class ValueInfo { } return $out->setTimezone(new \DateTimeZone("UTC")); } else { - $out = new \DateTimeImmutable($value, new \DateTimeZone("UTC")); - if ($out) { - return $out->setTimezone(new \DateTimeZone("UTC")); - } elseif ($strict && !$drop) { - throw new \Exception; - } + // if the string fails to parse it will produce an exception which is caught just below + return (new \DateTimeImmutable($value, new \DateTimeZone("UTC")))->setTimezone(new \DateTimeZone("UTC")); } } catch (\Exception $e) { if ($strict && !$drop) { diff --git a/tests/cases/Misc/TestValueInfo.php b/tests/cases/Misc/TestValueInfo.php index e17be63..d6f39b2 100644 --- a/tests/cases/Misc/TestValueInfo.php +++ b/tests/cases/Misc/TestValueInfo.php @@ -568,7 +568,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { null, ]; foreach ([ - /* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */ + /* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */ [null, null, null, null, null, null, null, null, null, null, null, null], [INF, null, null, null, null, null, null, null, null, null, null, null], [NAN, null, null, null, null, null, null, null, null, null, null, null], @@ -600,7 +600,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { [[], null, null, null, null, null, null, null, null, null, null, null], [$this->i("P1Y2D"), null, null, null, null, null, null, null, null, null, null, null], ["P1Y2D", null, null, null, null, null, null, null, null, null, null, null], - ] as $set) { + ] as $k => $set) { // shift the input value off the set $input = array_shift($set); // generate a set of tests for each target date formats @@ -612,7 +612,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { [false, true], [true, true], ] as [$strict, $drop]) { - yield [$input, $formats[$format], $exp, $strict, $drop]; + yield "Index #$k format \"$format\" strict:$strict drop:$drop" => [$input, $formats[$format], $exp, $strict, $drop]; } } } From 7e5d8494c433c951992ba5a00614ed68336a15fd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Apr 2022 14:33:19 -0400 Subject: [PATCH 19/36] Tests for selecting arrays of ranges --- lib/Database.php | 2 +- tests/cases/Database/SeriesArticle.php | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 3608e21..d201875 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1725,7 +1725,7 @@ class Database { $w[] = "{$colDefs[$col]} <= ?"; $t[] = $type; $v[] = $r[1]; - } elseif ($context->$m[1] === null) { + } elseif ($r[1] === null) { // range is open at the high end $w[] = "{$colDefs[$col]} >= ?"; $t[] = $type; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index b7d1eb0..a28ea59 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -530,6 +530,14 @@ trait SeriesArticle { 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], 'Before article 3' => [(new Context)->not->articleRange(3, null), [1,2]], 'Before article 19' => [(new Context)->not->articleRange(null, 19), [20]], + 'Marked or labelled in 2010 or 2015' => [(new Context)->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]], + 'Not marked or labelled in 2010 or 2015' => [(new Context)->not->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]], + 'Marked or labelled prior to 2010 or since 2015' => [(new Context)->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,8,19]], + 'Not marked or labelled prior to 2010 or since 2015' => [(new Context)->not->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,20]], + 'Modified in 2010 or 2015' => [(new Context)->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]], + 'Not modified in 2010 or 2015' => [(new Context)->not->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]], + 'Modified prior to 2010 or since 2015' => [(new Context)->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,19]], + 'Not modified prior to 2010 or since 2015' => [(new Context)->not->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,8,20]], ]; } @@ -1039,9 +1047,10 @@ trait SeriesArticle { public function provideArrayContextOptions(): iterable { foreach ([ "articles", "editions", - "subscriptions", "foldersShallow", //"folders", + "subscriptions", "foldersShallow", "folders", "tags", "tagNames", "labels", "labelNames", "searchTerms", "authorTerms", "annotationTerms", + "modifiedRanges", "markedRanges", ] as $method) { yield [$method]; } From e65069885b357002fca0ca6ea1202bc181e8cc22 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Apr 2022 18:30:13 -0400 Subject: [PATCH 20/36] Clean up obsolete FIXMEs --- lib/Db/MySQL/Statement.php | 2 +- lib/Db/PDOStatement.php | 2 +- lib/Db/PostgreSQL/Statement.php | 2 +- lib/Db/SQLite3/Statement.php | 2 +- sql/SQLite3/0.sql | 2 +- sql/SQLite3/2.sql | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php index 057225a..c6ec0fb 100644 --- a/lib/Db/MySQL/Statement.php +++ b/lib/Db/MySQL/Statement.php @@ -15,7 +15,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { self::T_DATETIME => "s", self::T_BINARY => "b", self::T_STRING => "s", - self::T_BOOLEAN => "i", + self::T_BOOLEAN => "i", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically ]; protected $db; diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index 4425093..ba3ca83 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -15,7 +15,7 @@ abstract class PDOStatement extends AbstractStatement { self::T_DATETIME => \PDO::PARAM_STR, self::T_BINARY => \PDO::PARAM_LOB, self::T_STRING => \PDO::PARAM_STR, - self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_BOOLEAN => \PDO::PARAM_INT, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically ]; protected $st; diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php index acb14a3..278bee9 100644 --- a/lib/Db/PostgreSQL/Statement.php +++ b/lib/Db/PostgreSQL/Statement.php @@ -15,7 +15,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { self::T_DATETIME => "timestamp(0) without time zone", self::T_BINARY => "bytea", self::T_STRING => "text", - self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_BOOLEAN => "smallint", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically ]; protected $db; diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index c97b1d8..b38e452 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -18,7 +18,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { self::T_DATETIME => \SQLITE3_TEXT, self::T_BINARY => \SQLITE3_BLOB, self::T_STRING => \SQLITE3_TEXT, - self::T_BOOLEAN => \SQLITE3_INTEGER, + self::T_BOOLEAN => \SQLITE3_INTEGER, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically ]; protected $db; diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 623c502..7a55606 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -16,7 +16,7 @@ create table arsse_users( avatar_type text, -- internal avatar image's MIME content type avatar_data blob, -- internal avatar image's binary data admin boolean default 0, -- whether the user is a member of the special "admin" group - rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this + rights integer not null default 0 -- temporary admin-rights marker ); create table arsse_users_meta( diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index 1894126..7876530 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -13,7 +13,7 @@ create table arsse_users_new( avatar_type text, -- internal avatar image's MIME content type avatar_data blob, -- internal avatar image's binary data admin boolean default 0, -- whether the user is a member of the special "admin" group - rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this + rights integer not null default 0 -- temporary admin-rights marker ); insert into arsse_users_new(id,password,name,avatar_type,avatar_data,admin,rights) select id,password,name,avatar_type,avatar_data,admin,rights from arsse_users; drop table arsse_users; From 17832ac63e771d446863d3e8d4b5cf1b0fcd0393 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Apr 2022 22:28:16 -0400 Subject: [PATCH 21/36] Allow timezone in TT-RSS search queries Does not quite work yet --- lib/REST/TinyTinyRSS/API.php | 3 ++- lib/REST/TinyTinyRSS/Search.php | 28 ++++++++++----------- tests/cases/REST/TinyTinyRSS/TestSearch.php | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d214709..e1c744c 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1520,7 +1520,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // handle the search string, if any if (isset($data['search'])) { - $c = Search::parse($data['search'], $c); + $tz = Arsse::$db->userPropertiesGet(Arsse::$user->id, false)['tz'] ?? "UTC"; + $c = Search::parse($data['search'], $tz, $c); if (!$c) { // the search string inherently returns an empty result, either directly or interacting with other input return new ResultEmpty; diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 966ea20..447808f 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -32,7 +32,7 @@ class Search { "" => "searchTerms", ]; - public static function parse(string $search, Context $context = null): ?Context { + public static function parse(string $search, string $tz, Context $context = null): ?Context { // normalize the input $search = strtolower(trim(preg_replace("<\s+>", " ", $search))); // set initial state @@ -88,7 +88,7 @@ class Search { continue 3; case '"': if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { - $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $context = self::processToken($context, $buffer, $tag, $flag_negative); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -135,7 +135,7 @@ class Search { while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; } - $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $context = self::processToken($context, $buffer, $tag, $flag_negative, $tz); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -145,7 +145,7 @@ class Search { case "": case '"': if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { - $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $context = self::processToken($context, $buffer, $tag, $flag_negative, $tz); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -178,7 +178,7 @@ class Search { if (!strlen($tag)) { $buffer = ":".$buffer; } - $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $context = self::processToken($context, $buffer, $tag, $flag_negative); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -191,7 +191,7 @@ class Search { if (!strlen($tag)) { $buffer = ":".$buffer; } - $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $context = self::processToken($context, $buffer, $tag, $flag_negative); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -221,7 +221,7 @@ class Search { switch ($char) { case "": case " ": - $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $context = self::processToken($context, $buffer, $tag, $flag_negative); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -241,7 +241,7 @@ class Search { case "": case '"': if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { - $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $context = self::processToken($context, $buffer, $tag, $flag_negative); $state = self::STATE_BEFORE_TOKEN; $flag_negative = false; $buffer = $tag = ""; @@ -282,7 +282,7 @@ class Search { return $context; } - protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { + protected static function processToken(Context $c, string $value, string $tag, bool $neg, string $tz = null): Context { if (!strlen($value) && !strlen($tag)) { return $c; } elseif (!strlen($value)) { @@ -290,8 +290,8 @@ class Search { $value = "$tag:"; $tag = ""; } - if ($date) { - return self::setDate($value, $c, $neg); + if ($tz !== null) { + return self::setDate($value, $c, $neg, $tz); } elseif (isset(self::FIELDS_BOOLEAN[$tag])) { return self::setBoolean($tag, $value, $c, $neg); } else { @@ -309,15 +309,15 @@ class Search { return $c->$type(array_merge($c->$type ?? [], [$value])); } - protected static function setDate(string $value, Context $c, bool $neg): Context { + protected static function setDate(string $value, Context $c, bool $neg, string $tz): Context { $spec = Date::normalize($value); // TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead if (!$spec) { return $c; } $day = $spec->format("Y-m-d"); - $start = $day."T00:00:00+00:00"; - $end = $day."T23:59:59+00:00"; + $start = $day."T00:00:00 $tz"; + $end = $day."T23:59:59 $tz"; // if a date is already set, the same date is a no-op; anything else is a contradiction $cc = $neg ? $c->not : $c; if ($cc->modifiedRange()) { diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php index 84ca200..c2e8c60 100644 --- a/tests/cases/REST/TinyTinyRSS/TestSearch.php +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -119,7 +119,7 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideSearchStrings */ public function testApplySearchToContext(string $search, $exp): void { - $act = Search::parse($search); + $act = Search::parse($search, "UTC"); $this->assertEquals($exp, $act); } } From 2c5b9a67686e58fc47758be98bfea77d5a2dedb8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Apr 2022 12:13:15 -0400 Subject: [PATCH 22/36] Fix missing TTRSS coverage --- tests/cases/REST/TinyTinyRSS/TestAPI.php | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index fe5c07b..5220a69 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1488,6 +1488,84 @@ LONG_STRING; ]; } + /** @dataProvider provideArticleListingsWithoutLabels */ + public function testListArticlesWithoutLabels(array $in, ResponseInterface $exp): void { + $in = array_merge(['op' => "getArticle", 'sid' => "PriestsOfSyrinx"], $in); + $this->dbMock->labelList->with("~")->returns(new Result([])); + $this->dbMock->labelList->with("~", false)->returns(new Result([])); + $this->dbMock->articleLabelsGet->with("~", 101)->returns([]); + $this->dbMock->articleLabelsGet->with("~", 102)->returns($this->v([1,3])); + $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101, 102])), "~")->returns(new Result($this->v($this->articles))); + $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101])), "~")->returns(new Result($this->v([$this->articles[0]]))); + $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([102])), "~")->returns(new Result($this->v([$this->articles[1]]))); + $this->assertMessage($exp, $this->req($in)); + } + + public function provideArticleListingsWithoutLabels(): iterable { + $exp = [ + [ + 'id' => "101", + 'guid' => null, + 'title' => 'Article title 1', + 'link' => 'http://example.com/1', + 'labels' => [], + 'unread' => true, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => '', + 'updated' => strtotime('2000-01-01T00:00:01Z'), + 'feed_id' => "8", + 'feed_title' => "Feed 11", + 'attachments' => [], + 'score' => 0, + 'note' => null, + 'lang' => "", + 'content' => '

Article content 1

', + ], + [ + 'id' => "102", + 'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7", + 'title' => 'Article title 2', + 'link' => 'http://example.com/2', + 'labels' => [], + 'unread' => false, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => "J. King", + 'updated' => strtotime('2000-01-02T00:00:02Z'), + 'feed_id' => "8", + 'feed_title' => "Feed 11", + 'attachments' => [ + [ + 'id' => "0", + 'content_url' => "http://example.com/text", + 'content_type' => "text/plain", + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => "102", + ], + ], + 'score' => 0, + 'note' => "Note 2", + 'lang' => "", + 'content' => '

Article content 2

', + ], + ]; + return [ + [[], $this->respErr("INCORRECT_USAGE")], + [['article_id' => 0], $this->respErr("INCORRECT_USAGE")], + [['article_id' => -1], $this->respErr("INCORRECT_USAGE")], + [['article_id' => "0,-1"], $this->respErr("INCORRECT_USAGE")], + [['article_id' => "101,102"], $this->respGood($exp)], + [['article_id' => "101"], $this->respGood([$exp[0]])], + [['article_id' => "102"], $this->respGood([$exp[1]])], + ]; + } + /** @dataProvider provideHeadlines */ public function testRetrieveHeadlines(bool $full, array $in, $out, Context $c, array $fields, array $order, ResponseInterface $exp): void { $base = ['op' => $full ? "getHeadlines" : "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"]; From 65b1bb4fcd5f2e2dd7370cd77230e1d21c1a6029 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Apr 2022 17:13:16 -0400 Subject: [PATCH 23/36] Allow multiple dates in TT-RSS searches --- CHANGELOG | 2 ++ .../020_Tiny_Tiny_RSS.md | 2 +- lib/REST/TinyTinyRSS/API.php | 2 +- lib/REST/TinyTinyRSS/Search.php | 13 ++---------- tests/cases/REST/TinyTinyRSS/TestSearch.php | 20 ++++++++++++------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 67c3cab..4663d69 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,8 @@ Version 0.1?.? (2022-??-??) =========================== Bug fixes: +- Allow multiple date ranges in search strings in Tiny Tiny RSS +- Honour user time zone when interpreting search strings in Tiny Tiny RSS - Perform MySQL table maintenance more reliably Version 0.10.2 (2022-04-04) diff --git a/docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md b/docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md index e34ca45..5d62a32 100644 --- a/docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md +++ b/docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md @@ -37,7 +37,7 @@ The Arsse does not currently support the entire protocol. Notably missing featur - Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` - Invalid dates are ignored rather than assumed to be `"1970-01-01"` - - Only a single negative date is allowed (this is a known bug rather than intentional) + - Specifying multiple non-negative dates usually returns no results as articles must match all specified dates simultaneously; The Arsse instead returns articles matching any of the specified dates - Dates are always relative to UTC - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index e1c744c..e167fa4 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1520,7 +1520,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // handle the search string, if any if (isset($data['search'])) { - $tz = Arsse::$db->userPropertiesGet(Arsse::$user->id, false)['tz'] ?? "UTC"; + $tz = Arsse::$user->propertiesGet(Arsse::$user->id, false)['tz'] ?? "UTC"; $c = Search::parse($data['search'], $tz, $c); if (!$c) { // the search string inherently returns an empty result, either directly or interacting with other input diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 447808f..3ddd24e 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -318,18 +318,9 @@ class Search { $day = $spec->format("Y-m-d"); $start = $day."T00:00:00 $tz"; $end = $day."T23:59:59 $tz"; - // if a date is already set, the same date is a no-op; anything else is a contradiction $cc = $neg ? $c->not : $c; - if ($cc->modifiedRange()) { - if (!$cc->modifiedRange[0] || !$cc->modifiedRange[1] || $cc->modifiedRange[0]->format("c") !== $start || $cc->modifiedRange[1]->format("c") !== $end) { - // FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this - throw new Exception; - } else { - return $c; - } - } - $cc->modifiedRange($start, $end); - return $c; + // NOTE: TTRSS treats multiple positive dates as contradictory; we instead treat them as complimentary instead, because it makes more sense + return $cc->modifiedRanges(array_merge($cc->modifiedRanges, [[$start, $end]])); } protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php index c2e8c60..47683f5 100644 --- a/tests/cases/REST/TinyTinyRSS/TestSearch.php +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -101,19 +101,19 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { 'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], 'Bare blank date' => ['@', new Context], 'Quoted blank date' => ['"@"', new Context], - 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], - 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], - 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], - 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], + 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], + 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], + 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], 'Invalid date' => ['@Bugaboo', new Context], 'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], 'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], 'Escaped quoted date 3' => ['"@Yesterday\\', new Context], 'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], 'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], - 'Contradictory dates' => ['@Yesterday @Today', null], - 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], - 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedRange("2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z")], + 'Contradictory dates' => ['@2010-01-01 @2015-01-01', (new Context)->modifiedRanges([["2010-01-01T00:00:00Z", "2010-01-01T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-01-01T23:59:59Z"]])], // This differs from TTRSS' behaviour + 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], + 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])], ]; } @@ -122,4 +122,10 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { $act = Search::parse($search, "UTC"); $this->assertEquals($exp, $act); } + + public function testApplySearchToContextWithTimeZone() { + $act = Search::parse("@2022-02-02", "America/Toronto"); + $exp = (new Context)->modifiedRanges([["2022-02-02T05:00:00Z", "2022-02-03T04:59:59Z"]]); + $this->assertEquals($exp, $act); + } } From 336207741d18f192b91ff83ce55c3511590c8003 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Apr 2022 17:37:10 -0400 Subject: [PATCH 24/36] Add missing API documentation --- lib/Database.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index d201875..b3d7598 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -41,7 +41,8 @@ use JKingWeb\Arsse\Rule\Exception as RuleException; * concerns, will typically follow different conventions. * * Note that operations on users should be performed with the User class rather - * than the Database class directly. This is to allow for alternate user sources. + * than the Database class directly. This is to allow for alternate user + * databases e.g. LDAP, although not such support for alternatives exists yet. */ class Database { /** The version number of the latest schema the interface is aware of */ @@ -275,6 +276,10 @@ class Database { return true; } + /** Renames a user + * + * This does not have an effect on their numeric ID, but has a cascading effect on many tables + */ public function userRename(string $user, string $name): bool { if ($user === $name) { return false; @@ -328,6 +333,11 @@ class Database { return true; } + /** Retrieves any metadata associated with a user + * + * @param string $user The user whose metadata is to be retrieved + * @param bool $includeLarge Whether to include values which can be arbitrarily large text + */ public function userPropertiesGet(string $user, bool $includeLarge = true): array { $basic = $this->db->prepare("SELECT num, admin from arsse_users where id = ?", "str")->run($user)->getRow(); if (!$basic) { @@ -345,6 +355,11 @@ class Database { return $meta; } + /** Set one or more metadata properties for a user + * + * @param string $user The user whose metadata is to be sedt + * @param array $data An associative array of property names and values + */ public function userPropertiesSet(string $user, array $data): bool { if (!$this->userExists($user)) { throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); From 26e431b1a592f351d340c094e5728d0b67ca1bc6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Apr 2022 17:57:31 -0400 Subject: [PATCH 25/36] Simplify more queries --- lib/Database.php | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index b3d7598..119e6bd 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -544,22 +544,27 @@ class Database { // check to make sure the parent exists, if one is specified $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( - "SELECT + "WITH RECURSIVE + folders as ( + select id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id + ) + select id, name, arsse_folders.parent as parent, coalesce(children,0) as children, coalesce(feeds,0) as feeds - FROM arsse_folders - left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id - left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" + from arsse_folders + left join (select parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (select folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id", + ["str", "strict int"], + [$user, $parent] ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { - $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); - $q->setWhere("id in (SELECT id from folders)"); + $q->setWhere("id in (select id from folders)"); } $q->setOrder("name"); return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); @@ -694,14 +699,14 @@ class Database { $p = $this->db->prepareArray( "WITH RECURSIVE target as ( - SELECT ? as userid, ? as source, ? as dest, ? as new_name + select ? as userid, ? as source, ? as dest, ? as new_name ), folders as ( - SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source + select id from arsse_folders join target on owner = userid and coalesce(parent,0) = source union all select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id ) - SELECT + select case when ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0))) then 1 else 0 end as extant, @@ -808,7 +813,14 @@ class Database { // create a complex query $integer = $this->db->sqlToken("integer"); $q = new Query( - "SELECT + "WITH RECURSIVE + topmost(f_id, top) as ( + select id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id + ), + folders(folder) as ( + select ? union all select id from arsse_folders join folders on parent = folder + ) + select s.id as id, s.feed as feed, f.url,source,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape, @@ -821,7 +833,7 @@ class Database { folder, t.top as top_folder, d.name as folder_name, dt.name as top_folder_name, coalesce(s.title, f.title) as title, coalesce((articles - hidden - marked), coalesce(articles,0)) as unread - FROM arsse_subscriptions as s + from arsse_subscriptions as s join arsse_feeds as f on f.id = s.feed left join topmost as t on t.f_id = s.folder left join arsse_folders as d on s.folder = d.id @@ -840,21 +852,19 @@ class Database { sum(hidden) as hidden, sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked from arsse_marks group by subscription - ) as mark_stats on mark_stats.subscription = s.id" + ) as mark_stats on mark_stats.subscription = s.id", + ["str", "int"], + [$user, $folder] ); $q->setWhere("s.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(s.title, f.title) collate $nocase"); - // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]); if ($id) { // if an ID is specified, add a suitable WHERE condition and bindings // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder $q->setWhere("s.id = ?", "int", $id); } elseif ($folder && $recursive) { - // if a folder is specified and we're listing recursively, 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 all select id from arsse_folders join folders on parent = folder", "int", $folder); - // add a suitable WHERE condition + // if a folder is specified and we're listing recursively, add a suitable WHERE condition $q->setWhere("folder in (select folder from folders)"); } elseif (!$recursive) { // if we're not listing recursively, match against only the specified folder (even if it is null) From 0c8f33c37cd60b8f1c17b94663a82abe6495ec66 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Apr 2022 21:24:57 -0400 Subject: [PATCH 26/36] Remove setCTE and pushCTE from query builder --- lib/Database.php | 84 ++++++++++++++++++++++++++-------- lib/Misc/Query.php | 43 ++--------------- tests/cases/Misc/TestQuery.php | 33 ++----------- 3 files changed, 72 insertions(+), 88 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 119e6bd..020eb1d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -882,12 +882,18 @@ class Database { // validate inputs $folder = $this->folderValidateId($user, $folder)['id']; // create a complex query - $q = new Query("SELECT count(*) from arsse_subscriptions"); + $q = new Query( + "WITH RECURSIVE + folders(folder) as ( + select ? union all select id from arsse_folders join folders on parent = folder + ) + select count(*) from arsse_subscriptions", + ["int"], + [$folder] + ); $q->setWhere("owner = ?", "str", $user); if ($folder) { - // if the specified folder exists, 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 all select id from arsse_folders join folders on parent = folder", "int", $folder); - // add a suitable WHERE condition + // if the specified folder exists, add a suitable WHERE condition $q->setWhere("folder in (select folder from folders)"); } return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); @@ -1882,10 +1888,23 @@ class Database { // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); // set read marks - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); - $q->pushCTE("target_articles(article,subscription)"); - $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $subq = $this->articleQuery($user, $context, ["id", "subscription"]); + $subq->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q = new Query( + "WITH RECURSIVE + target_articles(article, subscription) as ( + {$subq->getQuery()} + ) + update arsse_marks + set + \"read\" = ?, + touched = 1 + where + article in (select article from target_articles) + and subscription in (select distinct subscription from target_articles)", + [$subq->getTypes(), "bool"], + [$subq->getValues(), $data['read']] + ); $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); // get the articles associated with the requested editions if ($context->edition()) { @@ -1895,14 +1914,27 @@ class Database { } // set starred, hidden, and/or note marks (unless all requested editions actually do not exist) if ($context->article || $context->articles) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { + $setData = array_filter($data, function($v) { return isset($v); }); - [$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]); - $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + [$set, $setTypes, $setValues] = $this->generateSet($setData, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]); + $subq = $this->articleQuery($user, $context, ["id", "subscription"]); + $subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]); + $q = new Query( + "WITH RECURSIVE + target_articles(article, subscription) as ( + {$subq->getQuery()} + ) + update arsse_marks + set + touched = 1, + $set + where + article in (select article from target_articles) + and subscription in (select distinct subscription from target_articles)", + [$subq->getTypes(), $setTypes], + [$subq->getValues(), $setValues] + ); $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } // finally set the modification date for all touched marks and return the number of affected marks @@ -1923,17 +1955,29 @@ class Database { return 0; } } - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { + $setData = array_filter($data, function($v) { return isset($v); }); - [$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]); + [$set, $setTypes, $setValues] = $this->generateSet($setData, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]); if ($updateTimestamp) { $set .= ", modified = CURRENT_TIMESTAMP"; } - $q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + $subq = $this->articleQuery($user, $context, ["id", "subscription"]); + $subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]); + $q = new Query( + "WITH RECURSIVE + target_articles(article, subscription) as ( + {$subq->getQuery()} + ) + update arsse_marks + set + $set + where + article in (select article from target_articles) + and subscription in (select distinct subscription from target_articles)", + [$subq->getTypes(), $setTypes], + [$subq->getValues(), $setValues] + ); $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } $tr->commit(); diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index a2965dc..941ea78 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -10,9 +10,6 @@ class Query { protected $qBody = ""; // main query body protected $tBody = []; // main query parameter types protected $vBody = []; // main query parameter values - protected $qCTE = []; // Common table expression query components - protected $tCTE = []; // Common table expression type bindings - protected $vCTE = []; // Common table expression binding values protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values @@ -37,15 +34,6 @@ class Query { return $this; } - public function setCTE(string $tableSpec, string $body, $types = null, $values = null): self { - $this->qCTE[] = "$tableSpec as ($body)"; - if (!is_null($types)) { - $this->tCTE[] = $types; - $this->vCTE[] = $values; - } - return $this; - } - public function setWhere(string $where, $types = null, $values = null): self { $this->qWhere[] = $where; if (!is_null($types)) { @@ -84,33 +72,8 @@ class Query { return $this; } - public function pushCTE(string $tableSpec): self { - // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack - // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query - $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); - $this->tBody = []; - $this->vBody = []; - $this->qWhere = []; - $this->tWhere = []; - $this->vWhere = []; - $this->qWhereNot = []; - $this->tWhereNot = []; - $this->vWhereNot = []; - $this->order = []; - $this->group = []; - $this->setLimit(0, 0); - return $this; - } - public function __toString(): string { - $out = ""; - if (sizeof($this->qCTE)) { - // start with common table expressions - $out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." "; - } - // add the body - $out .= $this->buildQueryBody(); - return $out; + return $this->buildQueryBody(); } public function getQuery(): string { @@ -118,11 +81,11 @@ class Query { } public function getTypes(): array { - return ValueInfo::flatten([$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]); + return ValueInfo::flatten([$this->tBody, $this->tWhere, $this->tWhereNot]); } public function getValues(): array { - return ValueInfo::flatten([$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]); + return ValueInfo::flatten([$this->vBody, $this->vWhere, $this->vWhereNot]); } protected function buildQueryBody(): string { diff --git a/tests/cases/Misc/TestQuery.php b/tests/cases/Misc/TestQuery.php index db8a629..053d109 100644 --- a/tests/cases/Misc/TestQuery.php +++ b/tests/cases/Misc/TestQuery.php @@ -77,38 +77,15 @@ class TestQuery extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame([], $q->getValues()); } - public function testQueryWithCommonTableExpression(): void { - $q = (new Query("select * from table where a in (select * from cte where a = ?)", "int", 1))->setCTE("cte", "select * from other_table where a = ? and b = ?", ["str", "str"], [2, 3]); - $this->assertSame("WITH RECURSIVE cte as (select * from other_table where a = ? and b = ?) select * from table where a in (select * from cte where a = ?)", $q->getQuery()); - $this->assertSame(["str", "str", "int"], $q->getTypes()); - $this->assertSame([2, 3, 1], $q->getValues()); - // multiple CTEs - $q = (new Query("select * from table where a in (select * from cte1 join cte2 using (a) where a = ?)", "int", 1))->setCTE("cte1", "select * from other_table where a = ? and b = ?", ["str", "str"], [2, 3])->setCTE("cte2", "select * from other_table where c between ? and ?", ["datetime", "datetime"], [4, 5]); - $this->assertSame("WITH RECURSIVE cte1 as (select * from other_table where a = ? and b = ?), cte2 as (select * from other_table where c between ? and ?) select * from table where a in (select * from cte1 join cte2 using (a) where a = ?)", $q->getQuery()); - $this->assertSame(["str", "str", "datetime", "datetime", "int"], $q->getTypes()); - $this->assertSame([2, 3, 4, 5, 1], $q->getValues()); - } - - public function testQueryWithPushedCommonTableExpression(): void { - $q = (new Query("select * from table1"))->setWhere("a between ? and ?", ["datetime", "datetime"], [1, 2]) - ->setCTE("cte1", "select * from table2 where a = ? and b = ?", ["str", "str"], [3, 4]) - ->pushCTE("cte2") - ->setBody("select * from table3 join cte1 using (a) join cte2 using (a) where a = ?", "int", 5); - $this->assertSame("WITH RECURSIVE cte1 as (select * from table2 where a = ? and b = ?), cte2 as (select * from table1 WHERE a between ? and ?) select * from table3 join cte1 using (a) join cte2 using (a) where a = ?", $q->getQuery()); - $this->assertSame(["str", "str", "datetime", "datetime", "int"], $q->getTypes()); - $this->assertSame([3, 4, 1, 2, 5], $q->getValues()); - } - public function testComplexQuery(): void { - $q = (new query("select *, ? as const from table", "datetime", 1)) + $q = (new query("SELECT *, ? as const from table", "datetime", 1)) ->setWhereNot("b = ?", "bool", 2) ->setGroup("col1", "col2") ->setWhere("a = ?", "str", 3) ->setLimit(4, 5) - ->setOrder("col3") - ->setCTE("cte", "select ? as const", "int", 6); - $this->assertSame("WITH RECURSIVE cte as (select ? as const) select *, ? as const from table WHERE a = ? AND NOT (b = ?) GROUP BY col1, col2 ORDER BY col3 LIMIT 4 OFFSET 5", $q->getQuery()); - $this->assertSame(["int", "datetime", "str", "bool"], $q->getTypes()); - $this->assertSame([6, 1, 3, 2], $q->getValues()); + ->setOrder("col3"); + $this->assertSame("SELECT *, ? as const from table WHERE a = ? AND NOT (b = ?) GROUP BY col1, col2 ORDER BY col3 LIMIT 4 OFFSET 5", $q->getQuery()); + $this->assertSame(["datetime", "str", "bool"], $q->getTypes()); + $this->assertSame([1, 3, 2], $q->getValues()); } } From 206c5c0012888f7eca08d907311275056f814ad6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Apr 2022 22:32:10 -0400 Subject: [PATCH 27/36] Fill in union context --- lib/Context/Context.php | 3 +- .../{RootMembers.php => RootContext.php} | 2 +- lib/Context/UnionContext.php | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) rename lib/Context/{RootMembers.php => RootContext.php} (91%) create mode 100644 lib/Context/UnionContext.php diff --git a/lib/Context/Context.php b/lib/Context/Context.php index e7cdc89..4ab9595 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -6,8 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -class Context extends AbstractContext { - use RootMembers; +class Context extends RootContext { use BooleanMembers; use ExclusionMembers; diff --git a/lib/Context/RootMembers.php b/lib/Context/RootContext.php similarity index 91% rename from lib/Context/RootMembers.php rename to lib/Context/RootContext.php index d5048b2..950a867 100644 --- a/lib/Context/RootMembers.php +++ b/lib/Context/RootContext.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -trait RootMembers { +class RootContext extends AbstractContext { public $limit = 0; public $offset = 0; diff --git a/lib/Context/UnionContext.php b/lib/Context/UnionContext.php new file mode 100644 index 0000000..257e501 --- /dev/null +++ b/lib/Context/UnionContext.php @@ -0,0 +1,41 @@ +contexts); + } + + public function offsetGet(mixed $offset): mixed { + return $this->contexts[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void { + $this->contexts[$offset ?? count($this->contexts)] = $value; + } + + public function offsetUnset(mixed $offset): void { + unset($this->contexts[$offset]); + } + + public function count(): int { + return count($this->contexts); + } + + public function getIterator(): \Traversable { + foreach ($this->contexts as $k => $c) { + yield $k => $c; + } + } + + public function __construct(Context ...$context) { + $this->contexts = $context; + } +} From 630536d7899ab2a29be752eba7c9f4b1f4ad1c8e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 16:35:46 -0400 Subject: [PATCH 28/36] Tests for union context --- lib/Context/RootContext.php | 2 +- lib/Context/UnionContext.php | 11 ++++++++--- tests/cases/Misc/TestContext.php | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/Context/RootContext.php b/lib/Context/RootContext.php index 950a867..3b938e8 100644 --- a/lib/Context/RootContext.php +++ b/lib/Context/RootContext.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -class RootContext extends AbstractContext { +abstract class RootContext extends AbstractContext { public $limit = 0; public $offset = 0; diff --git a/lib/Context/UnionContext.php b/lib/Context/UnionContext.php index 257e501..db5a98f 100644 --- a/lib/Context/UnionContext.php +++ b/lib/Context/UnionContext.php @@ -10,7 +10,7 @@ class UnionContext extends RootContext implements \ArrayAccess, \Countable, \Ite protected $contexts = []; public function offsetExists(mixed $offset): bool { - return array_key_exists($offset, $this->contexts); + return isset($this->contexts[$offset]); } public function offsetGet(mixed $offset): mixed { @@ -18,7 +18,12 @@ class UnionContext extends RootContext implements \ArrayAccess, \Countable, \Ite } public function offsetSet(mixed $offset, mixed $value): void { - $this->contexts[$offset ?? count($this->contexts)] = $value; + assert($value instanceof RootContext, new \Exception("Union contexts may only contain other non-exclusion contexts")); + if (isset($offset)) { + $this->contexts[$offset] = $value; + } else { + $this->contexts[] = $value; + } } public function offsetUnset(mixed $offset): void { @@ -35,7 +40,7 @@ class UnionContext extends RootContext implements \ArrayAccess, \Countable, \Ite } } - public function __construct(Context ...$context) { + public function __construct(RootContext ...$context) { $this->contexts = $context; } } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 1f8b638..f02f6fa 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -8,12 +8,14 @@ namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Context\ExclusionContext; +use JKingWeb\Arsse\Context\UnionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** * @covers \JKingWeb\Arsse\Context\Context * @covers \JKingWeb\Arsse\Context\ExclusionContext + * @covers \JKingWeb\Arsse\Context\UnionContext */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { protected $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange']; @@ -150,4 +152,27 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($c1, $c1->not->article(null)); $this->assertSame($c2, $c2->not->article(null)); } + + public function testExerciseAUnionContext(): void { + $c1 = new UnionContext; + $c2 = new Context; + $c3 = new UnionContext; + $this->assertSame(0, sizeof($c1)); + $c1[] = $c2; + $c1[2] = $c3; + $this->assertSame(2, sizeof($c1)); + $this->assertSame($c2, $c1[0]); + $this->assertSame($c3, $c1[2]); + $this->assertSame(null, $c1[1]); + unset($c1[0]); + $this->assertFalse(isset($c1[0])); + $this->assertTrue(isset($c1[2])); + $c1[] = $c2; + $act = []; + foreach($c1 as $k => $v) { + $act[$k] = $v; + } + $exp = [2 => $c3, $c2]; + $this->assertSame($exp, $act); + } } From a44fe103d8269b9e20e5eab87f161d1b0b0abd31 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 16:37:16 -0400 Subject: [PATCH 29/36] Prototype for nesting query filters --- lib/Misc/Query.php | 36 +++----------------- lib/Misc/QueryFilter.php | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 lib/Misc/QueryFilter.php diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 941ea78..b190288 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -6,16 +6,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Misc; -class Query { +class Query extends QueryFilter { protected $qBody = ""; // main query body protected $tBody = []; // main query parameter types protected $vBody = []; // main query parameter values - protected $qWhere = []; // WHERE clause components - protected $tWhere = []; // WHERE clause type bindings - protected $vWhere = []; // WHERE clause binding values - protected $qWhereNot = []; // WHERE NOT clause components - protected $tWhereNot = []; // WHERE NOT clause type bindings - protected $vWhereNot = []; // WHERE NOT clause binding values protected $group = []; // GROUP BY clause components protected $order = []; // ORDER BY clause components protected $limit = 0; @@ -34,24 +28,6 @@ class Query { return $this; } - public function setWhere(string $where, $types = null, $values = null): self { - $this->qWhere[] = $where; - if (!is_null($types)) { - $this->tWhere[] = $types; - $this->vWhere[] = $values; - } - return $this; - } - - public function setWhereNot(string $where, $types = null, $values = null): self { - $this->qWhereNot[] = $where; - if (!is_null($types)) { - $this->tWhereNot[] = $types; - $this->vWhereNot[] = $values; - } - return $this; - } - public function setGroup(string ...$column): self { foreach ($column as $col) { $this->group[] = $col; @@ -81,11 +57,11 @@ class Query { } public function getTypes(): array { - return ValueInfo::flatten([$this->tBody, $this->tWhere, $this->tWhereNot]); + return ValueInfo::flatten([$this->tBody, $this->getWhereTypes()]); } public function getValues(): array { - return ValueInfo::flatten([$this->vBody, $this->vWhere, $this->vWhereNot]); + return ValueInfo::flatten([$this->vBody, $this->getWhereValues()]); } protected function buildQueryBody(): string { @@ -94,11 +70,7 @@ class Query { $out .= $this->qBody; // add any WHERE terms if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { - $where = implode(" AND ", $this->qWhere); - $whereNot = implode(" OR ", $this->qWhereNot); - $whereNot = strlen($whereNot) ? "NOT ($whereNot)" : ""; - $where = implode(" AND ", array_filter([$where, $whereNot])); - $out .= " WHERE $where"; + $out .= " WHERE ".$this->buildWhereBody(); } // add any GROUP BY terms if (sizeof($this->group)) { diff --git a/lib/Misc/QueryFilter.php b/lib/Misc/QueryFilter.php new file mode 100644 index 0000000..12c3bd3 --- /dev/null +++ b/lib/Misc/QueryFilter.php @@ -0,0 +1,71 @@ +qWhere[] = $where; + if (!is_null($types)) { + $this->tWhere[] = $types ?? []; + $this->vWhere[] = $values; + } + return $this; + } + + public function setWhereNot(string $where, $types = null, $values = null): self { + $this->qWhereNot[] = $where; + if (!is_null($types)) { + $this->tWhereNot[] = $types; + $this->vWhereNot[] = $values; + } + return $this; + } + + public function setFilter(self $filter): self { + $this->qWhere[] = "(".$filter->buildWhereBody().")"; + $this->tWhere[] = $filter->getWhereTypes(); + $this->vWhere[] = $filter->getWhereValues(); + return $this; + } + + protected function getWhereTypes(): array { + return ValueInfo::flatten([$this->tWhere, $this->tWhereNot]); + } + + protected function getWhereValues(): array { + return ValueInfo::flatten([$this->vWhere, $this->vWhereNot]); + } + + public function getTypes(): array { + return $this->getWhereTypes(); + } + + public function getValues(): array { + return $this->getWhereValues(); + } + + protected function buildWhereBody(): string { + $glue = $this->filterRestrictive ? " AND " : " OR "; + $where = implode($glue, $this->qWhere); + $whereNot = implode(" OR ", $this->qWhereNot); + $whereNot = strlen($whereNot) ? "NOT ($whereNot)" : ""; + return implode($glue, array_filter([$where, $whereNot])); + } + + public function __toString() { + return $this->buildWhereBody(); + } +} From c6cc2a1a42a5feae07e9a6affd513e1f766442b2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 17:23:41 -0400 Subject: [PATCH 30/36] Restore coverage for Query class --- lib/Misc/QueryFilter.php | 6 +++--- tests/cases/Misc/TestQuery.php | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/Misc/QueryFilter.php b/lib/Misc/QueryFilter.php index 12c3bd3..f4a663d 100644 --- a/lib/Misc/QueryFilter.php +++ b/lib/Misc/QueryFilter.php @@ -16,7 +16,7 @@ class QueryFilter { public $filterRestrictive = true; - public function setWhere(string $where, $types = null, $values = null): self { + public function setWhere(string $where, $types = null, $values = null): static { $this->qWhere[] = $where; if (!is_null($types)) { $this->tWhere[] = $types ?? []; @@ -25,7 +25,7 @@ class QueryFilter { return $this; } - public function setWhereNot(string $where, $types = null, $values = null): self { + public function setWhereNot(string $where, $types = null, $values = null): static { $this->qWhereNot[] = $where; if (!is_null($types)) { $this->tWhereNot[] = $types; @@ -34,7 +34,7 @@ class QueryFilter { return $this; } - public function setFilter(self $filter): self { + public function setFilter(self $filter): static { $this->qWhere[] = "(".$filter->buildWhereBody().")"; $this->tWhere[] = $filter->getWhereTypes(); $this->vWhere[] = $filter->getWhereValues(); diff --git a/tests/cases/Misc/TestQuery.php b/tests/cases/Misc/TestQuery.php index 053d109..f78ac2d 100644 --- a/tests/cases/Misc/TestQuery.php +++ b/tests/cases/Misc/TestQuery.php @@ -8,7 +8,10 @@ namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Misc\Query; -/** @covers \JKingWeb\Arsse\Misc\Query */ +/** + * @covers \JKingWeb\Arsse\Misc\Query + * @covers \JKingWeb\Arsse\Misc\QueryFilter + */ class TestQuery extends \JKingWeb\Arsse\Test\AbstractTest { public function testBasicQuery(): void { $q = new Query("select * from table where a = ?", "int", 3); From 300225439cfac1fb698f72d5c39c43bef5f247aa Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 19:04:08 -0400 Subject: [PATCH 31/36] Fix trivial error in Miniflux This is not a bug as the behaviour that should have been implemented was not being relied upon --- lib/REST/Miniflux/V1.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index 09a24f3..00915ba 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -644,9 +644,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { * * - "num": The user's numeric ID, * - "root": The effective name of the root folder + * - "tz": The time zone preference of the user, or UTC if not set */ protected function userMeta(string $user): array { - $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); + $meta = Arsse::$user->propertiesGet($user, false); return [ 'num' => $meta['num'], 'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), From f51acb426493aa9e4bef19dff4e31dc32ee7fb80 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 19:10:11 -0400 Subject: [PATCH 32/36] Build exceptions correctly in Miniflux for clarity --- lib/REST/Miniflux/V1.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index 00915ba..0d6e712 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -690,7 +690,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { if ($folder === 0) { // folder 0 doesn't actually exist in the database, so its name is kept as user metadata if (!strlen(trim($title))) { - throw new ExceptionInput("whitespace"); + throw new ExceptionInput("whitespace", ['field' => "title", 'action' => __FUNCTION__]); } $title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name']; } else { @@ -1024,7 +1024,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { // find the entry we want $entry = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS)->getRow(); if (!$entry) { - throw new ExceptionInput("idMissing"); + throw new ExceptionInput("idMissing", ['id' => $id, 'field' => 'entry']); } $out = $this->transformEntry($entry, $meta['num'], $meta['tz']); // next transform the parent feed of the entry From d64dc751f9f5d957102b837e90e779e9662390f9 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 20:53:05 -0400 Subject: [PATCH 33/36] Tests for query filters --- lib/Misc/QueryFilter.php | 10 +++++++--- tests/cases/Misc/TestQuery.php | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/Misc/QueryFilter.php b/lib/Misc/QueryFilter.php index f4a663d..f099487 100644 --- a/lib/Misc/QueryFilter.php +++ b/lib/Misc/QueryFilter.php @@ -13,8 +13,7 @@ class QueryFilter { protected $qWhereNot = []; // WHERE NOT clause components protected $tWhereNot = []; // WHERE NOT clause type bindings protected $vWhereNot = []; // WHERE NOT clause binding values - - public $filterRestrictive = true; + protected $filterRestrictive = true; // Whether to glue WHERE conditions with OR (false) or AND (true) public function setWhere(string $where, $types = null, $values = null): static { $this->qWhere[] = $where; @@ -34,13 +33,18 @@ class QueryFilter { return $this; } - public function setFilter(self $filter): static { + public function setWhereGroup(self $filter): static { $this->qWhere[] = "(".$filter->buildWhereBody().")"; $this->tWhere[] = $filter->getWhereTypes(); $this->vWhere[] = $filter->getWhereValues(); return $this; } + public function setWhereRestrictive(bool $restrictive): static { + $this->filterRestrictive = $restrictive; + return $this; + } + protected function getWhereTypes(): array { return ValueInfo::flatten([$this->tWhere, $this->tWhereNot]); } diff --git a/tests/cases/Misc/TestQuery.php b/tests/cases/Misc/TestQuery.php index f78ac2d..e638d76 100644 --- a/tests/cases/Misc/TestQuery.php +++ b/tests/cases/Misc/TestQuery.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Misc\Query; +use JKingWeb\Arsse\Misc\QueryFilter; /** * @covers \JKingWeb\Arsse\Misc\Query @@ -91,4 +92,24 @@ class TestQuery extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame(["datetime", "str", "bool"], $q->getTypes()); $this->assertSame([1, 3, 2], $q->getValues()); } -} + + public function testNestedWhereConditions(): void { + $q = new Query("SELECT *, ? as const from table", "datetime", 1); + $f = new QueryFilter; + $f->setWhere("a = ?", "str", "ook")->setWhere("b = c")->setWhere("c = ?", "int", 42); + $this->assertSame("a = ? AND b = c AND c = ?", (string) $f); + $this->assertSame(["str", "int"], $f->getTypes()); + $this->assertSame(["ook", 42], $f->getValues()); + $q->setWhereGroup($f); + $f->setWhereRestrictive(false); + $this->assertSame("a = ? OR b = c OR c = ?", (string) $f); + $q->setWhereGroup($f); + $this->assertSame("SELECT *, ? as const from table WHERE (a = ? AND b = c AND c = ?) AND (a = ? OR b = c OR c = ?)", $q->getQuery()); + $this->assertSame(["datetime", "str", "int", "str", "int"], $q->getTypes()); + $this->assertSame([1, "ook", 42, "ook", 42], $q->getValues()); + $q->setWhereRestrictive(false); + $this->assertSame("SELECT *, ? as const from table WHERE (a = ? AND b = c AND c = ?) OR (a = ? OR b = c OR c = ?)", $q->getQuery()); + $this->assertSame(["datetime", "str", "int", "str", "int"], $q->getTypes()); + $this->assertSame([1, "ook", 42, "ook", 42], $q->getValues()); + } +} \ No newline at end of file From 761b3d53336f1e867a8dbc0ba676b5e6addc2dea Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Apr 2022 23:28:47 -0400 Subject: [PATCH 34/36] Return removed articles correctly in Miniflux --- CHANGELOG | 1 + .../030_Supported_Protocols/005_Miniflux.md | 1 - lib/Database.php | 141 ++++++++++-------- lib/REST/Miniflux/V1.php | 16 +- tests/cases/Database/SeriesArticle.php | 5 +- tests/cases/REST/Miniflux/TestV1.php | 116 +++++++------- 6 files changed, 155 insertions(+), 125 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4663d69..f6263f2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ Version 0.1?.? (2022-??-??) =========================== Bug fixes: +- Return all removed articles when multiple statuses are requested in Miniflux - Allow multiple date ranges in search strings in Tiny Tiny RSS - Honour user time zone when interpreting search strings in Tiny Tiny RSS - Perform MySQL table maintenance more reliably diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md index ebbb442..3b2457c 100644 --- a/docs/en/030_Supported_Protocols/005_Miniflux.md +++ b/docs/en/030_Supported_Protocols/005_Miniflux.md @@ -39,7 +39,6 @@ Miniflux version 2.0.28 is emulated, though not all features are implemented - Filtering rules may not function identically (see below for details) - The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked - Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization -- Querying articles for both read/unread and removed statuses will not return all removed articles - Search strings will match partial words - OPML import either succeeds or fails atomically: if one feed fails, no feeds are imported diff --git a/lib/Database.php b/lib/Database.php index 020eb1d..3bfed97 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -10,7 +10,10 @@ use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\UnionContext; +use JKingWeb\Arsse\Context\RootContext; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\QueryFilter; use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\Misc\URL; use JKingWeb\Arsse\Rule\Rule; @@ -1518,33 +1521,11 @@ class Database { * If an empty column list is supplied, a count of articles matching the context is queried instead * * @param string $user The user whose articles are to be queried - * @param Context $context The search context + * @param RootContext $context The search context * @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()) { - $this->labelValidateId($user, $context->labelName, true); - } - // prepare the output column list; the column definitions are also used later + protected function articleQuery(string $user, RootContext $context, array $cols = ["id"]): Query { + // prepare the output column list; the column definitions are also used for ordering $colDefs = $this->articleColumns(); if (!$cols) { // if no columns are specified return a count; don't borther with sorting @@ -1602,6 +1583,67 @@ class Database { [$user, $user, $user, $user, $user, $user] ); $q->setLimit($context->limit, $context->offset); + if ($context instanceof UnionContext) { + // if the context is a union context, we compute each context in turn + $q->setWhereRestrictive(false); + foreach ($context as $c) { + $q->setWhereGroup($this->articleFilter($c)); + } + } else { + // if the context is not a union, first validate input to catch 404s and the like + 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()) { + $this->labelValidateId($user, $context->labelName, true); + } + // ensure any used array-type context options contain at least one member + foreach ([ + "articles", + "editions", + "subscriptions", + "folders", + "foldersShallow", + "labels", + "labelNames", + "tags", + "tagNames", + "searchTerms", + "titleTerms", + "authorTerms", + "annotationTerms", + "modifiedRanges", + "markedRanges", + ] as $m) { + if ($context->$m() && !$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); + } + } + // next compute the context, supplying the query to manipulate directly + $this->articleFilter($context, $q); + } + // return the query + return $q; + } + + protected function articleFilter(Context $context, QueryFilter $q = null) { + $q = $q ?? new QueryFilter; + $colDefs = $this->articleColumns(); // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, and a data type; the "between" operator has special handling @@ -1639,9 +1681,6 @@ class Database { } } 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 - } [$clause, $types, $values] = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); } else { @@ -1691,9 +1730,6 @@ class Database { foreach ($options as $m => [$cte, $outerCol, $selection, $innerCol, $op, $type]) { if ($context->$m()) { if ($op === "in") { - if (!$context->$m) { - throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } [$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues); } else { @@ -1727,9 +1763,6 @@ class Database { return $colDefs[$c]; }, $columns); if ($context->$m()) { - if (!$context->$m) { - throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } $q->setWhere(...$this->generateSearch($context->$m, $columns)); } // handle the exclusionary version @@ -1744,31 +1777,20 @@ class Database { ]; foreach ($options as $m => [$col, $type]) { if ($context->$m()) { - if (!$context->$m) { - throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } - $w = []; - $t = []; - $v = []; + $subq = (new QueryFilter)->setWhereRestrictive(false); foreach ($context->$m as $r) { if ($r[0] === null) { // range is open at the low end - $w[] = "{$colDefs[$col]} <= ?"; - $t[] = $type; - $v[] = $r[1]; + $subq->setWhere("{$colDefs[$col]} <= ?", $type, $r[1]); } elseif ($r[1] === null) { // range is open at the high end - $w[] = "{$colDefs[$col]} >= ?"; - $t[] = $type; - $v[] = $r[0]; + $subq->setWhere("{$colDefs[$col]} >= ?", $type, $r[0]); } else { // range is bounded in both directions - $w[] = "{$colDefs[$col]} BETWEEN ? AND ?"; - $t[] = [$type, $type]; - $v[] = $r; + $subq->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r); } } - $q->setWhere("(".implode(" OR ", $w).")", $t, $v); + $q->setWhereGroup($subq); } // handle the exclusionary version if ($context->not->$m() && $context->not->$m) { @@ -1786,7 +1808,6 @@ class Database { } } } - // return the query return $q; } @@ -1795,11 +1816,11 @@ class Database { * If an empty column list is supplied, a count of articles is returned instead * * @param string $user The user whose articles are to be listed - * @param Context $context The search context + * @param RootContext $context The search context * @param array $fieldss 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 * @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance */ - public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result { + public function articleList(string $user, RootContext $context = null, array $fields = ["id"], array $sort = []): Db\Result { // make a base query based on context and output columns $context = $context ?? new Context; $q = $this->articleQuery($user, $context, $fields); @@ -1841,9 +1862,9 @@ class Database { /** Returns a count of articles which match the given query context * * @param string $user The user whose articles are to be counted - * @param Context $context The search context + * @param RootContext $context The search context */ - public function articleCount(string $user, Context $context = null): int { + public function articleCount(string $user, RootContext $context = null): int { $context = $context ?? new Context; $q = $this->articleQuery($user, $context, []); return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); @@ -1860,10 +1881,10 @@ class Database { * * @param string $user The user who owns the articles to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged - * @param Context $context The query context to match articles against + * @param RootContext $context The query context to match articles against * @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user */ - public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int { + public function articleMark(string $user, array $data, RootContext $context = null, bool $updateTimestamp = true): int { $data = [ 'read' => $data['read'] ?? null, 'starred' => $data['starred'] ?? null, @@ -2147,7 +2168,7 @@ class Database { } /** Returns the numeric identifier of the most recent edition of an article matching the given context */ - public function editionLatest(string $user, Context $context = null): int { + public function editionLatest(string $user, RootContext $context = null): int { $context = $context ?? new Context; $q = $this->articleQuery($user, $context, ["latest_edition"]); return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue(); @@ -2350,11 +2371,11 @@ class Database { * * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label - * @param Context $context The query context matching the desired articles + * @param RootContext $context The query context matching the desired articles * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) */ - public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int { + public function labelArticlesSet(string $user, $id, RootContext $context, int $mode = self::ASSOC_ADD, bool $byName = false): int { assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode)); // validate the tag ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index 0d6e712..6897472 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -12,6 +12,8 @@ use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\UnionContext; +use JKingWeb\Arsse\Context\RootContext; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception as ImportException; @@ -886,12 +888,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { ]); } - protected function computeContext(array $query, Context $c = null): Context { + protected function computeContext(array $query, Context $c): RootContext { if ($query['before'] && $query['before']->getTimestamp() === 0) { $query['before'] = null; // NOTE: This workaround is needed for compatibility with "Microflux for Miniflux", an Android Client } - $c = ($c ?? new Context) - ->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences + $c->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences ->offset($query['offset']) ->starred($query['starred']) ->modifiedRange($query['after'], $query['before']) // FIXME: This may not be the correct date field @@ -904,17 +905,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { $c->folder($query['category_id'] - 1); } } - // FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden $status = array_unique($query['status']); sort($status); if ($status === ["read", "removed"]) { - $c->unread(false); + $c1 = $c; + $c2 = clone $c; + $c = new UnionContext($c1->unread(false), $c2->hidden(true)); } elseif ($status === ["read", "unread"]) { $c->hidden(false); } elseif ($status === ["read"]) { $c->hidden(false)->unread(false); } elseif ($status === ["removed", "unread"]) { - $c->unread(true); + $c1 = $c; + $c2 = clone $c; + $c = new UnionContext($c1->unread(true), $c2->hidden(true)); } elseif ($status === ["removed"]) { $c->hidden(true); } elseif ($status === ["unread"]) { diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index a28ea59..efd78e1 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -9,6 +9,8 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\UnionContext; +use JKingWeb\Arsse\Context\RootContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -423,7 +425,7 @@ trait SeriesArticle { } /** @dataProvider provideContextMatches */ - public function testListArticlesCheckingContext(Context $c, array $exp): void { + public function testListArticlesCheckingContext(RootContext $c, array $exp): void { $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c, ["id"], ["id"])->getAll(), "id"); sort($ids); sort($exp); @@ -538,6 +540,7 @@ trait SeriesArticle { 'Not modified in 2010 or 2015' => [(new Context)->not->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]], 'Modified prior to 2010 or since 2015' => [(new Context)->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,19]], 'Not modified prior to 2010 or since 2015' => [(new Context)->not->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,8,20]], + 'Either read or hidden' => [(new UnionContext((new Context)->unread(false), (new Context)->hidden(true))), [1, 6, 19]], ]; } diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index 5a8c651..9623fd3 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -10,6 +10,8 @@ use Eloquent\Phony\Mock\Handle\InstanceHandle; use Eloquent\Phony\Phpunit\Phony; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\RootContext; +use JKingWeb\Arsse\Context\UnionContext; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Db\Transaction; @@ -711,7 +713,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideEntryQueries */ - public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp): void { + public function testGetEntries(string $url, ?RootContext $c, ?array $order, $out, bool $count, ResponseInterface $exp): void { $this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS))); $this->dbMock->articleCount->returns(2112); if ($out instanceof \Exception) { @@ -742,62 +744,62 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { $c = (new Context)->limit(100); $o = ["modified_date"]; // the default sort order return [ - ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)], - ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)], - ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)], - ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)], - ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)], - ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)], - ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)], - ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)], - ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)], - ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)], - ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)], - ["/entries", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?category_id=47", (clone $c)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?category_id=1", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=read", (clone $c)->unread(false)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=removed", (clone $c)->hidden(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=unread&status=read", (clone $c)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=unread&status=removed", (clone $c)->unread(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=removed&status=read", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?status=removed&status=read&status=unread", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?starred", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?after=0", (clone $c)->modifiedRange(0, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before=1", (clone $c)->modifiedRange(null, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before=1&after=0", (clone $c)->modifiedRange(0, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?after_entry_id=42", (clone $c)->articleRange(43, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?before_entry_id=47", (clone $c)->articleRange(null, 46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])], - ["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])], - ["/entries?direction=asc", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=id", $c, ["id"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=published_at", $c, ["modified_date"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=category_id", $c, ["top_folder"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=category_title", $c, ["top_folder_name"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=status", $c, ["hidden", "unread desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=id&direction=desc", $c, ["id desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=published_at&direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=category_id&direction=desc", $c, ["top_folder desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=category_title&direction=desc", $c, ["top_folder_name desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?order=status&direction=desc", $c, ["hidden desc", "unread"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/entries?category_id=2112", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("MissingCategory")], - ["/feeds/42/entries", (clone $c)->subscription(42), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/feeds/42/entries?category_id=47", (clone $c)->subscription(42)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/feeds/2112/entries", (clone $c)->subscription(2112), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)], - ["/categories/42/entries", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/categories/42/entries?category_id=47", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/categories/42/entries?starred", (clone $c)->folder(41)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/categories/1/entries", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], - ["/categories/2112/entries", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)], + ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)], + ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)], + ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)], + ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)], + ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)], + ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)], + ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)], + ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)], + ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)], + ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)], + ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)], + ["/entries", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?category_id=47", (clone $c)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?category_id=1", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=read", (clone $c)->unread(false)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=removed", (clone $c)->hidden(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=unread&status=read", (clone $c)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=unread&status=removed", new UnionContext((clone $c)->unread(true), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=removed&status=read", new UnionContext((clone $c)->unread(false), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=removed&status=read&status=removed", new UnionContext((clone $c)->unread(false), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?status=removed&status=read&status=unread", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?starred", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?after=0", (clone $c)->modifiedRange(0, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before=1", (clone $c)->modifiedRange(null, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before=1&after=0", (clone $c)->modifiedRange(0, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?after_entry_id=42", (clone $c)->articleRange(43, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?before_entry_id=47", (clone $c)->articleRange(null, 46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])], + ["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])], + ["/entries?direction=asc", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=id", $c, ["id"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=published_at", $c, ["modified_date"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=category_id", $c, ["top_folder"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=category_title", $c, ["top_folder_name"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=status", $c, ["hidden", "unread desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=id&direction=desc", $c, ["id desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=published_at&direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=category_id&direction=desc", $c, ["top_folder desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=category_title&direction=desc", $c, ["top_folder_name desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?order=status&direction=desc", $c, ["hidden desc", "unread"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/entries?category_id=2112", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("MissingCategory")], + ["/feeds/42/entries", (clone $c)->subscription(42), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/feeds/42/entries?category_id=47", (clone $c)->subscription(42)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/feeds/2112/entries", (clone $c)->subscription(2112), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)], + ["/categories/42/entries", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/categories/42/entries?category_id=47", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/categories/42/entries?starred", (clone $c)->folder(41)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/categories/1/entries", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])], + ["/categories/2112/entries", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)], ]; } From 90b66241b3a0e9f7f79e180306f28956c0da6837 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Apr 2022 13:50:35 -0400 Subject: [PATCH 35/36] Fixes for PHP 7 --- lib/Context/UnionContext.php | 12 ++++++++---- lib/Misc/QueryFilter.php | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/Context/UnionContext.php b/lib/Context/UnionContext.php index db5a98f..a8c1749 100644 --- a/lib/Context/UnionContext.php +++ b/lib/Context/UnionContext.php @@ -9,15 +9,18 @@ namespace JKingWeb\Arsse\Context; class UnionContext extends RootContext implements \ArrayAccess, \Countable, \IteratorAggregate { protected $contexts = []; - public function offsetExists(mixed $offset): bool { + #[\ReturnTypeWillChange] + public function offsetExists($offset) { return isset($this->contexts[$offset]); } - public function offsetGet(mixed $offset): mixed { + #[\ReturnTypeWillChange] + public function offsetGet($offset) { return $this->contexts[$offset] ?? null; } - public function offsetSet(mixed $offset, mixed $value): void { + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) { assert($value instanceof RootContext, new \Exception("Union contexts may only contain other non-exclusion contexts")); if (isset($offset)) { $this->contexts[$offset] = $value; @@ -26,7 +29,8 @@ class UnionContext extends RootContext implements \ArrayAccess, \Countable, \Ite } } - public function offsetUnset(mixed $offset): void { + #[\ReturnTypeWillChange] + public function offsetUnset($offset) { unset($this->contexts[$offset]); } diff --git a/lib/Misc/QueryFilter.php b/lib/Misc/QueryFilter.php index f099487..a15a63a 100644 --- a/lib/Misc/QueryFilter.php +++ b/lib/Misc/QueryFilter.php @@ -15,7 +15,7 @@ class QueryFilter { protected $vWhereNot = []; // WHERE NOT clause binding values protected $filterRestrictive = true; // Whether to glue WHERE conditions with OR (false) or AND (true) - public function setWhere(string $where, $types = null, $values = null): static { + public function setWhere(string $where, $types = null, $values = null): self { $this->qWhere[] = $where; if (!is_null($types)) { $this->tWhere[] = $types ?? []; @@ -24,7 +24,7 @@ class QueryFilter { return $this; } - public function setWhereNot(string $where, $types = null, $values = null): static { + public function setWhereNot(string $where, $types = null, $values = null): self { $this->qWhereNot[] = $where; if (!is_null($types)) { $this->tWhereNot[] = $types; @@ -33,14 +33,14 @@ class QueryFilter { return $this; } - public function setWhereGroup(self $filter): static { + public function setWhereGroup(self $filter): self { $this->qWhere[] = "(".$filter->buildWhereBody().")"; $this->tWhere[] = $filter->getWhereTypes(); $this->vWhere[] = $filter->getWhereValues(); return $this; } - public function setWhereRestrictive(bool $restrictive): static { + public function setWhereRestrictive(bool $restrictive): self { $this->filterRestrictive = $restrictive; return $this; } From 59358ec35bff99c8b26bff1e7a5686ec2686df7e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Apr 2022 17:11:18 -0400 Subject: [PATCH 36/36] More PHP 7 fixes --- composer.lock | 2 +- tests/cases/Misc/TestContext.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.lock b/composer.lock index a9fbefc..57bae4a 100644 --- a/composer.lock +++ b/composer.lock @@ -1424,5 +1424,5 @@ "platform-overrides": { "php": "7.1.33" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index f02f6fa..008d4bf 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -25,7 +25,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testSetContextOptions(string $method, array $input, $output, bool $not): void { $parent = new Context; $c = ($not) ? $parent->not : $parent; - $default = (new \ReflectionProperty($c, $method))->getDefaultValue(); + $default = (new \ReflectionClass($c))->getDefaultProperties()[$method]; $this->assertFalse($c->$method(), "Context method did not initially return false"); if (in_array($method, $this->ranges)) { $this->assertEquals([null, null], $c->$method, "Context property is not initially a two-member falsy array");