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 00000000..f6065f8e --- /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 00000000..e28101e6 --- /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 00000000..a6f69015 --- /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 e7323ea7..d72e8142 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 00000000..7b7ecc4a --- /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 00000000..426adb27 --- /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 7b7ecc4a..917326ed 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) { + 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 oldestArticle(int $spec = null) { + 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 latestEdition(int $spec = null) { + 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 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); + 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 426adb27..8b0b63b8 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 f3320ce2..6f63395d 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 20e6c356..c581d881 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 7cba4061..ca8535c6 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 111cf2f0..21bc6fb0 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 74f315a5..757d476b 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 ea3dbe65..966ea205 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 eace73af..82bcef82 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 46ecaaff..7e1d6af1 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 a9896c73..4dfaec8e 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 f1dd8d33..a87daf6f 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 9e980e99..b637b0fe 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 74a12b95..e9ed0e57 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 6999b0d6..84ca2005 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 6f63395d..5781f776 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 c581d881..7ad69ba5 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 ca8535c6..09a24f32 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 21bc6fb0..7ec195cc 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 757d476b..d2147095 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 82bcef82..a1eafda5 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 7e1d6af1..a05cab63 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 4dfaec8e..6a618b68 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 a87daf6f..5a8c651f 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 b637b0fe..f58f87d6 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 e9ed0e57..fe5c07b0 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 f6065f8e..d86ef390 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 e28101e6..e13be6f2 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 a6f69015..00000000 --- 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 917326ed..d9d82c77 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 8b0b63b8..00000000 --- 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 a1eafda5..2a4aa511 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 a05cab63..fa053483 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 592c2f6f..e7cdc894 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 00000000..d5048b25 --- /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 fa053483..af778c0b 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"); + /** @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"); + } + $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->assertEquals(null, $c->$method, "Context property $method is not initially falsy"); + $this->assertTime($c->$method, $output, "Context property did not return the expected results after setting"); } + } else { + $this->assertSame($c->$method, $output, "Context property did not return the expected results after setting"); } - } - - 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; - } - $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()); - } + // 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 | 114 ++++++++++++++++++----------------------------- 1 file changed, 44 insertions(+), 70 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 5781f776..7c60a9f8 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,75 +1641,38 @@ 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); - } - 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; - } - $q->setWhereNot("$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 ($seen) { - $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; - $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); + // 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 + } + [$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); + } } } // handle complex context options 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 7c60a9f8..6f02d0af 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 d9d82c77..05c2614c 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 6f02d0af..cec47f94 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 af778c0b..93e343db 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 05c2614c..1efa53a3 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 cec47f94..40e0e342 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 2a4aa511..b7d1eb01 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 40e0e342..1e0950a8 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 1e0950a8..ca6696f5 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 ca6696f5..088262af 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 7c5a1109..b4f91297 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 93e343db..78bc11ea 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 1efa53a3..62f2a403 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 0aba7700..8b31590b 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 89a26007..fe7f344c 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 78bc11ea..1f8b6380 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 7b30e117..e17be633 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 | 168 +++++++++++++++++++------------ 2 files changed, 105 insertions(+), 67 deletions(-) diff --git a/lib/Context/ExclusionMembers.php b/lib/Context/ExclusionMembers.php index 62f2a403..b326f6d9 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 088262af..3608e214 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 + } + // 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 { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } - [$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]); - } 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 { - $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 fa856e11..67c3cabd 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 9aca8189..c61762a1 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 8b31590b..d03949cd 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 e17be633..d6f39b23 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 3608e214..d201875c 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 b7d1eb01..a28ea592 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 057225a6..c6ec0fb3 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 4425093c..ba3ca832 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 acb14a3b..278bee94 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 c97b1d8e..b38e4521 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 623c502f..7a55606d 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 1894126d..7876530d 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 d2147095..e1c744cd 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 966ea205..447808f7 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 84ca2005..c2e8c604 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 fe5c07b0..5220a69f 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 67c3cabd..4663d693 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 e34ca456..5d62a322 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 e1c744cd..e167fa4b 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 447808f7..3ddd24ef 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 c2e8c604..47683f52 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 d201875c..b3d7598b 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 b3d7598b..119e6bde 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 119e6bde..020eb1d2 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 a2965dc2..941ea780 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 db8a6295..053d1091 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 e7cdc894..4ab9595c 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 d5048b25..950a8670 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 00000000..257e5019 --- /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 950a8670..3b938e8e 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 257e5019..db5a98f3 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 1f8b6380..f02f6fa7 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 941ea780..b190288f 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 00000000..12c3bd30 --- /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 12c3bd30..f4a663d6 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 053d1091..f78ac2d2 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 09a24f32..00915ba6 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 00915ba6..0d6e712c 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 f4a663d6..f099487d 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 f78ac2d2..e638d76a 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 4663d693..f6263f2e 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 ebbb4423..3b2457c4 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 020eb1d2..3bfed979 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 0d6e712c..6897472f 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 a28ea592..efd78e18 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 5a8c651f..9623fd3d 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 db5a98f3..a8c17499 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 f099487d..a15a63a5 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 a9fbefc9..57bae4a3 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 f02f6fa7..008d4bf9 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");