diff --git a/CHANGELOG b/CHANGELOG index 10832aa..af5159f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +Version 0.7.0 (2019-??-??) +========================== + +New features: +- Support for basic freeform searching in Tiny Tiny RSS + Version 0.6.1 (2019-01-23) ========================== diff --git a/README.md b/README.md index 2cec044..d4fca7f 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - The `getPref` operation is not implemented; it returns `UNKNOWN_METHOD` - The `shareToPublished` operation is not implemented; it returns `UNKNOWN_METHOD` - Setting an article's "published" flag with the `updateArticle` operation is not implemented and will gracefully fail -- The `search` parameter of the `getHeadlines` operation is not implemented; the operation will proceed as if no search string were specified - The `sanitize`, `force_update`, and `has_sandbox` parameters of the `getHeadlines` operation are ignored - String `feed_id` values for the `getCompactHeadlines` operation are not supported and will yield an `INCORRECT_USAGE` error - Articles are limited to a single attachment rather than multiple attachments @@ -141,6 +140,13 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Feed, category, and label names are normally unrestricted; The Arsse rejects empty strings, as well as strings composed solely of whitespace - Discovering multiple feeds during `subscribeToFeed` processing normally produces an error; The Arsse instead chooses the first feed it finds - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead +- 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"` + - Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error + - 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) + - 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 - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` - The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds diff --git a/lib/Context/Context.php b/lib/Context/Context.php new file mode 100644 index 0000000..858409f --- /dev/null +++ b/lib/Context/Context.php @@ -0,0 +1,61 @@ +not = new ExclusionContext($this); + } + + public function __clone() { + // clone the exclusion context as well + $this->not = clone $this->not; + } + + /** @codeCoverageIgnore */ + public function __destruct() { + unset($this->not); + } + + public function reverse(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + 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); + } + + 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 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/Misc/Context.php b/lib/Context/ExclusionContext.php similarity index 66% rename from lib/Misc/Context.php rename to lib/Context/ExclusionContext.php index 93e4ac4..d5299fe 100644 --- a/lib/Misc/Context.php +++ b/lib/Context/ExclusionContext.php @@ -4,38 +4,54 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Misc; +namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; -class Context { - public $reverse = false; - public $limit = 0; - public $offset = 0; +class ExclusionContext { public $folder; public $folderShallow; public $subscription; + public $edition; + public $article; + public $editions; + public $articles; + public $label; + public $labelName; + public $annotationTerms; + public $searchTerms; + public $titleTerms; + public $authorTerms; public $oldestArticle; public $latestArticle; public $oldestEdition; public $latestEdition; - public $unread = null; - public $starred = null; public $modifiedSince; public $notModifiedSince; public $markedSince; public $notMarkedSince; - public $edition; - public $article; - public $editions; - public $articles; - public $label; - public $labelName; - public $labelled = null; - public $annotated = null; protected $props = []; + protected $parent; + + public function __construct(self $c = null) { + $this->parent = $c; + } + + public function __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) { @@ -46,13 +62,13 @@ class Context { $this->props[$prop] = true; $this->$prop = $value; } - return $this; + return $this->parent ?? $this; } else { return isset($this->props[$prop]); } } - protected function cleanArray(array $spec): array { + protected function cleanIdArray(array $spec): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { if (ValueInfo::id($spec[$a])) { @@ -61,19 +77,20 @@ class Context { $spec[$a] = 0; } } - return array_values(array_filter($spec)); + return array_values(array_unique(array_filter($spec))); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - 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); + 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) { @@ -88,85 +105,97 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestArticle(int $spec = null) { + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function oldestArticle(int $spec = null) { + public function article(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestEdition(int $spec = null) { + public function editions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function oldestEdition(int $spec = null) { + public function articles(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function unread(bool $spec = null) { + public function label(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function starred(bool $spec = null) { + public function labelName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); + public function annotationTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); + public function searchTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function markedSince($spec = null) { - $spec = Date::normalize($spec); + public function titleTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); + public function authorTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function edition(int $spec = null) { + public function latestArticle(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function article(int $spec = null) { + public function oldestArticle(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function editions(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanArray($spec); - } + public function latestEdition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function articles(array $spec = null) { - if (isset($spec)) { - $spec = $this->cleanArray($spec); - } + public function oldestEdition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function label(int $spec = null) { + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelName(string $spec = null) { + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelled(bool $spec = null) { + public function markedSince($spec = null) { + $spec = Date::normalize($spec); return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function annotated(bool $spec = null) { + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); return $this->act(__FUNCTION__, func_num_args(), $spec); } } diff --git a/lib/Database.php b/lib/Database.php index dfa9e7c..61eefa5 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -9,7 +9,8 @@ namespace JKingWeb\Arsse; use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -127,7 +128,7 @@ class Database { /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value * - * Returns an indexed array containing the clause text, and an array of types + * Returns an indexed array containing the clause text, an array of types, and the array of values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value @@ -136,6 +137,7 @@ class Database { $out = [ "", // query clause [], // binding types + $values, // binding values ]; if (sizeof($values)) { // the query clause is just a series of question marks separated by commas @@ -149,6 +151,37 @@ class Database { return $out; } + /** Computes basic LIKE-based text search constraints for use in a WHERE clause + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * The clause is structured such that all terms must be present across any of the columns + * + * @param string[] $terms The terms to search for + * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms + */ + protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { + $clause = []; + $types = []; + $values = []; + $like = $this->db->sqlToken("like"); + foreach($terms as $term) { + $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); + $term = "%$term%"; + $spec = []; + foreach ($cols as $col) { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } + $clause[] = "(".implode(" or ", $spec).")"; + } + $glue = $matchAny ? "or" : "and"; + $clause = "(".implode(" $glue ", $clause).")"; + return [$clause, $types, $values]; + } + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); @@ -351,7 +384,7 @@ class Database { * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier - * @param boolean $recursive Whether to list all descendents, or only direct children + * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) */ public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. @@ -469,7 +502,7 @@ class Database { * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder - * @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function folderValidateId(string $user, $id = null, bool $subject = false): array { // if the specified ID is not a non-negative integer (or null), this will always fail @@ -808,7 +841,7 @@ class Database { * * @param string $user The user who owns the subscription to be validated * @param integer|null $id The identifier of the subscription to validate - * @param boolean $subject Whether the subscription is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { @@ -1065,8 +1098,30 @@ class Database { * @param array $cols The columns to request in the result set */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + $this->labelValidateId($user, $context->labelName, true); + } + // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); - // prepare the output column list $colDefs = [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", @@ -1076,6 +1131,7 @@ class Database { 'content' => "arsse_articles.content", 'guid' => "arsse_articles.guid", 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", 'subscription' => "arsse_subscriptions.id", 'feed' => "arsse_subscriptions.feed", 'starred' => "coalesce(arsse_marks.starred,0)", @@ -1084,7 +1140,7 @@ class Database { 'published_date' => "arsse_articles.published", 'edited_date' => "arsse_articles.edited", 'modified_date' => "arsse_articles.modified", - 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(arsse_label_members.modified, '0001-01-01 00:00:00'))", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", 'media_url' => "arsse_enclosures.url", 'media_type' => "arsse_enclosures.type", @@ -1112,114 +1168,151 @@ class Database { join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id 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 - left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1 - left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id", - ["str"], - [$user] + join ( + 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 + ) as label_stats on label_stats.article = arsse_articles.id", + ["str", "str"], + [$user, $user] ); - $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); - if ($cols) { - // if there are no output columns requested we're getting a count and should not group, but otherwise we should - $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); - } $q->setLimit($context->limit, $context->offset); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $this->subscriptionValidateId($user, $context->subscription); - // filter for the subscription - $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); - } elseif ($context->folder()) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $context->folder); - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); - // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); - } elseif ($context->folderShallow()) { - // if a shallow folder is specified, make sure it exists - $this->folderValidateId($user, $context->folderShallow); - // if it does exist, filter for that folder only - $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); - } - if ($context->edition()) { - // if an edition is specified, first validate it, then filter for it - $this->articleValidateEdition($user, $context->edition); - $q->setWhere("latest_editions.edition = ?", "int", $context->edition); - } elseif ($context->article()) { - // if an article is specified, first validate it, then filter for it - $this->articleValidateId($user, $context->article); - $q->setWhere("arsse_articles.id = ?", "int", $context->article); - } - if ($context->editions()) { - // if multiple specific editions have been requested, filter against the list - if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore - } - list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); - } elseif ($context->articles()) { - // if multiple specific articles have been requested, prepare a CTE to list them and their articles - if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + // handle the simple context options + $options = [ + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", "", 1], + "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", "", 1], + "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], + "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], + "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], + "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], + "folderShallow" => ["folder", "=", "int", "", 1], + "subscription" => ["subscription", "=", "int", "", 1], + "unread" => ["unread", "=", "bool", "", 1], + "starred" => ["starred", "=", "bool", "", 1], + ]; + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!$context->$m()) { + // context is not being used + continue; + } elseif (is_array($context->$m)) { + // context option is an array of values + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } 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); } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); } - // filter based on label by ID or name - if ($context->labelled()) { - // any label (true) or no label (false) - $isOrIsNot = (!$context->labelled ? "is" : "is not"); - $q->setWhere("arsse_labels.id $isOrIsNot null"); - } elseif ($context->label() || $context->labelName()) { - // specific label ID or name - if ($context->label()) { - $id = $this->labelValidateId($user, $context->label, false)['id']; + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty + continue; + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->not->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + } else { + // option has already been paired + continue; + } } else { - $id = $this->labelValidateId($user, $context->labelName, true)['id']; + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } - $q->setWhere("arsse_labels.id = ?", "int", $id); - } - // filter based on article or edition offset - if ($context->oldestArticle()) { - $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); - } - if ($context->latestArticle()) { - $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); } - if ($context->oldestEdition()) { - $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); - } - if ($context->latestEdition()) { - $q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition); - } - // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) - if ($context->modifiedSince()) { - $q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); - } - if ($context->notModifiedSince()) { - $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); - } - if ($context->markedSince()) { - $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); + // handle complex context options + if ($context->annotated()) { + $comp = ($context->annotated) ? "<>" : "="; + $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } - if ($context->notMarkedSince()) { - $q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince); + if ($context->labelled()) { + // any label (true) or no label (false) + $op = $context->labelled ? ">" : "="; + $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } - // filter for un/read and un/starred status if specified - if ($context->unread()) { - $q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); + if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { + $q->setCTE("labelled(article,label_id,label_name)","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", "str", $user); + if ($context->label()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); + } + if ($context->not->label()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); + } + if ($context->labelName()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); + } + if ($context->not->labelName()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); + } } - if ($context->starred()) { - $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); + if ($context->folder()) { + // add a common table expression to list the folder and its children so that we select from the entire subtree + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); + } + 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 select id from arsse_folders join folders_excluded on parent = 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)"); + } + // handle text-matching context options + $options = [ + "titleTerms" => [10, ["arsse_articles.title"]], + "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], + "authorTerms" => [10, ["arsse_articles.author"]], + "annotationTerms" => [20, ["arsse_marks.note"]], + ]; + foreach ($options as $m => list($max, $cols)) { + if (!$context->$m()) { + continue; + } elseif (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); + } + $q->setWhere(...$this->generateSearch($context->$m, $cols)); } - // filter based on whether the article has a note - if ($context->annotated()) { - $comp = ($context->annotated) ? "<>" : "="; - $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); + // further handle exclusionary text-matching context options + foreach ($options as $m => list($max, $cols)) { + if (!$context->not->$m()) { + continue; + } elseif (!$context->not->$m) { + continue; + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query return $q; @@ -1257,7 +1350,7 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1436,7 +1529,7 @@ class Database { * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed - * @param boolean $byName Whether to return the label names instead of the numeric label identifiers + * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) */ public function articleLabelsGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1836,7 +1929,7 @@ class Database { * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) - * @param boolean $subject Whether the label is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { if (!$byName && !ValueInfo::id($id)) { diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index d3486db..959a550 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -73,6 +73,7 @@ interface Driver { * * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL * - "nocase": the name of a general-purpose case-insensitive collation sequence + * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 513ce99..08c439d 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { switch (strtolower($token)) { case "nocase": return '"und-x-icu"'; + case "like": + return "ilike"; default: return $token; } diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index d7a2c7f..5a1b0b8 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -20,6 +20,9 @@ class Query { 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; @@ -69,6 +72,15 @@ class Query { return true; } + public function setWhereNot(string $where, $types = null, $values = null): bool { + $this->qWhereNot[] = $where; + if (!is_null($types)) { + $this->tWhereNot[] = $types; + $this->vWhereNot[] = $values; + } + return true; + } + public function setGroup(string ...$column): bool { foreach ($column as $col) { $this->group[] = $col; @@ -94,13 +106,16 @@ class Query { public function pushCTE(string $tableSpec, string $join = ''): bool { // 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->vBody, $this->vWhere]); + $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); $this->jCTE = []; $this->tBody = []; $this->vBody = []; $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qWhereNot = []; + $this->tWhereNot = []; + $this->vWhereNot = []; $this->qJoin = []; $this->tJoin = []; $this->vJoin = []; @@ -129,11 +144,11 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere]; + return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere]; + return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot]; } public function getJoinTypes(): array { @@ -173,8 +188,12 @@ class Query { $out .= " ".implode(" ", $this->qJoin); } // add any WHERE terms - if (sizeof($this->qWhere)) { - $out .= " WHERE ".implode(" AND ", $this->qWhere); + 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"; } // add any GROUP BY terms if (sizeof($this->group)) { diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 84beda3..7f4301c 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 4ddea6f..a3572ba 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -12,7 +12,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; @@ -49,7 +49,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'sid' => ValueInfo::T_STRING, // session ID 'seq' => ValueInfo::T_INT, // request number from client 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login` - 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed` + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed` 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories` 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds` 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories @@ -76,7 +76,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines` 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines` - 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented) + 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` 'field' => ValueInfo::T_INT, // which state to change in `updateArticle` 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle` 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note @@ -1478,7 +1478,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { default: throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore } - // TODO: implement searching + // handle the search string, if any + if (isset($data['search'])) { + $c = Search::parse($data['search'], $c); + if (!$c) { + // the search string inherently returns an empty result, either directly or interacting with other input + return new ResultEmpty; + } + } // handle sorting switch ($data['order_by']) { case "date_reverse": diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php new file mode 100644 index 0000000..4ff634b --- /dev/null +++ b/lib/REST/TinyTinyRSS/Search.php @@ -0,0 +1,361 @@ + "unread", + "star" => "starred", + "note" => "annotated", + "pub" => "published", // TODO: not implemented + ]; + const FIELDS_TEXT = [ + "title" => "titleTerms", + "author" => "authorTerms", + "note" => "annotationTerms", + "" => "searchTerms", + ]; + + public static function parse(string $search, Context $context = null) { + // normalize the input + $search = strtolower(trim(preg_replace("<\s+>", " ", $search))); + // set initial state + $tokens = []; + $pos = -1; + $stop = strlen($search); + $state = self::STATE_BEFORE_TOKEN; + $buffer = ""; + $tag = ""; + $flag_negative = false; + $context = $context ?? new Context; + // process + try { + while (++$pos <= $stop) { + $char = @$search[$pos]; + switch ($state) { + case self::STATE_BEFORE_TOKEN: + switch ($char) { + case "": + continue 3; + case " ": + continue 3; + case '"': + if ($flag_negative) { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } else { + $state = self::STATE_BEFORE_TOKEN_QUOTED; + } + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + continue 3; + } + case self::STATE_BEFORE_TOKEN_QUOTED: + switch ($char) { + case "": + continue 3; + case '"': + if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE_QUOTED; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + } + case self::STATE_IN_DATE: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_DATE_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_DATE; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_TOKEN_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG: + switch ($char) { + case "": + case " ": + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 3; + case ":"; + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + case ":": + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + continue 3; + } + default: + throw new \Exception; // @codeCoverageIgnore + } + } + } catch (Exception $e) { + return null; + } + return $context; + } + + protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { + if (!strlen($value) && !strlen($tag)) { + return $c; + } elseif (!strlen($value)) { + // if a tag has an empty value, the tag is treated as a search term instead + $value = "$tag:"; + $tag = ""; + } + if ($date) { + return self::setDate($value, $c, $neg); + } elseif (isset(self::FIELDS_BOOLEAN[$tag])) { + return self::setBoolean($tag, $value, $c, $neg); + } else { + return self::addTerm($tag, $value, $c, $neg); + } + } + + protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context { + $c = $neg ? $c->not : $c; + $type = self::FIELDS_TEXT[$tag] ?? ""; + if (!$type) { + $value = "$tag:$value"; + $type = self::FIELDS_TEXT[""]; + } + return $c->$type(array_merge($c->$type ?? [], [$value])); + } + + protected static function setDate(string $value, Context $c, bool $neg): 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"; + // 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) { + // 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); + return $c; + } + + protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { + $set = ["true" => true, "false" => false][$value] ?? null; + if (is_null($set)) { + return self::addTerm($tag, $value, $c, $neg); + } else { + // apply negation + $set = $neg ? !$set : $set; + if ($tag === "pub") { + // TODO: this needs to be implemented correctly if the Published feed is implemented + // currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything) + if ($set) { + throw new Exception; + } else { + return $c; + } + } else { + $field = (self::FIELDS_BOOLEAN[$tag] ?? ""); + if (!$c->$field()) { + // field has not yet been set; set it + return $c->$field($set); + } elseif ($c->$field == $set) { + // field is already set to same value; do nothing + return $c; + } else { + // contradiction: query would return no results + throw new Exception; + } + } + } + } +} diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index b40056e..219d4c0 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -66,7 +66,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { // get the name of the test's test series - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); static::clearData(); static::setConf(); if (strlen(static::$failureReason)) { @@ -88,7 +88,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { // call the series-specific teardown method - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); $tearDown = "tearDown".$this->series; $this->$tearDown(); // clean up diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index c3c4425..3887d78 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -111,13 +111,13 @@ trait SeriesArticle { 'modified' => "datetime", ], 'rows' => [ - [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -377,6 +377,87 @@ trait SeriesArticle { unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); } + /** @dataProvider provideContextMatches */ + public function testListArticlesCheckingContext(Context $c, array $exp) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + } + + public function provideContextMatches() { + return [ + "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], + "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], + "Leaf folder" => [(new Context)->folder(6), [7,8]], + "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], + "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], + "Subscription" => [(new Context)->subscription(5), [19,20]], + "Unread" => [(new Context)->subscription(5)->unread(true), [20]], + "Read" => [(new Context)->subscription(5)->unread(false), [19]], + "Starred" => [(new Context)->starred(true), [1,20]], + "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], + "Starred and Read in subscription" => [(new Context)->starred(true)->unread(false)->subscription(5), []], + "Annotated" => [(new Context)->annotated(true), [2]], + "Not annotated" => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], + "Labelled" => [(new Context)->labelled(true), [1,5,8,19,20]], + "Not labelled" => [(new Context)->labelled(false), [2,3,4,6,7]], + "Not after edition 999" => [(new Context)->subscription(5)->latestEdition(999), [19]], + "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], + "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], + "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], + "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], + "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], + "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + "With label ID 1" => [(new Context)->label(1), [1,19]], + "With label ID 2" => [(new Context)->label(2), [1,5,20]], + "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], + "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], + "Article ID 20" => [(new Context)->article(20), [20]], + "Edition ID 1001" => [(new Context)->edition(1001), [20]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], + "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], + "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], + "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], + "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], + "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], + "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], + "Search title 4" => [(new Context)->titleTerms(["two title"]), []], + "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], + "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], + "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], + "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], + "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], + "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + "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]], + "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]], + ]; + } + public function testRetrieveArticleIdsForEditions() { $exp = [ 1 => 1, @@ -414,88 +495,6 @@ trait SeriesArticle { $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } - public function testListArticlesCheckingContext() { - $compareIds = function(array $exp, Context $c) { - $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); - sort($ids); - sort($exp); - $this->assertEquals($exp, $ids); - }; - // get all items for user - $exp = [1,2,3,4,5,6,7,8,19,20]; - $compareIds($exp, new Context); - $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); - // get items from a folder tree - $compareIds([5,6,7,8], (new Context)->folder(1)); - // get items from a leaf folder - $compareIds([7,8], (new Context)->folder(6)); - // get items from a non-leaf folder without descending - $compareIds([1,2,3,4], (new Context)->folderShallow(0)); - $compareIds([5,6], (new Context)->folderShallow(1)); - // get items from a single subscription - $exp = [19,20]; - $compareIds($exp, (new Context)->subscription(5)); - // get un/read items from a single subscription - $compareIds([20], (new Context)->subscription(5)->unread(true)); - $compareIds([19], (new Context)->subscription(5)->unread(false)); - // get starred articles - $compareIds([1,20], (new Context)->starred(true)); - $compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); - $compareIds([1], (new Context)->starred(true)->unread(false)); - $compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); - // get items relative to edition - $compareIds([19], (new Context)->subscription(5)->latestEdition(999)); - $compareIds([19], (new Context)->subscription(5)->latestEdition(19)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); - // get items relative to article ID - $compareIds([1,2,3], (new Context)->latestArticle(3)); - $compareIds([19,20], (new Context)->oldestArticle(19)); - // get items relative to (feed) modification date - $exp = [2,4,6,8,20]; - $compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); - $exp = [1,3,5,7,19]; - $compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); - // get items relative to (user) modification date (both marks and labels apply) - $compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); - $compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); - $compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); - $compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); - // paged results - $compareIds([1], (new Context)->limit(1)); - $compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); - $compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); - $compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); - // reversed results - $compareIds([20], (new Context)->reverse(true)->limit(1)); - $compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); - $compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); - $compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); - // get articles by label ID - $compareIds([1,19], (new Context)->label(1)); - $compareIds([1,5,20], (new Context)->label(2)); - // get articles by label name - $compareIds([1,19], (new Context)->labelName("Interesting")); - $compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // get articles with any or no label - $compareIds([1,5,8,19,20], (new Context)->labelled(true)); - $compareIds([2,3,4,6,7], (new Context)->labelled(false)); - // get a specific article or edition - $compareIds([20], (new Context)->article(20)); - $compareIds([20], (new Context)->edition(1001)); - // get multiple specific articles or editions - $compareIds([1,20], (new Context)->articles([1,20,50])); - $compareIds([1,20], (new Context)->editions([1,1001,50])); - // get articles base on whether or not they have notes - $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $compareIds([2], (new Context)->annotated(true)); - // get specific starred articles - $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); - } - public function testListArticlesOfAMissingFolder() { $this->assertException("idMissing", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->folder(1)); @@ -985,4 +984,24 @@ trait SeriesArticle { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->articleCategoriesGet($this->user, 19); } + + public function testSearchTooFewTerms() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); + } + + public function testSearchTooManyTerms() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); + } + + public function testSearchTooFewTermsInNote() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + } + + public function testSearchTooManyTermsInNote() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105))); + } } diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 8347ce5..e6fc426 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 682c688..4967e84 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -94,6 +94,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testTranslateAToken() { $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest")); $this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase")); + $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like")); $this->assertSame("distinct", $this->drv->sqlToken("distinct")); } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 07d6adb..d134c0f 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -6,14 +6,15 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Misc\ValueInfo; -/** @covers \JKingWeb\Arsse\Misc\Context */ +/** @covers \JKingWeb\Arsse\Context\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -48,11 +49,16 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'labelName' => "Rush", 'labelled' => true, 'annotated' => true, + 'searchTerms' => ["foo", "bar"], + 'annotationTerms' => ["foo", "bar"], + 'titleTerms' => ["foo", "bar"], + 'authorTerms' => ["foo", "bar"], + 'not' => (new Context)->subscription(5), ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -70,7 +76,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } - public function testCleanArrayValues() { + public function testCleanIdArrayValues() { $methods = ["articles", "editions"]; $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; $out = [1,2, 3]; @@ -79,4 +85,26 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCleanStringArrayValues() { + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; + $now = new \DateTime; + $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; + $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCloneAContext() { + $c1 = new Context; + $c2 = clone $c1; + $this->assertEquals($c1, $c2); + $this->assertEquals($c1->not, $c2->not); + $this->assertNotSame($c1, $c2); + $this->assertNotSame($c1->not, $c2->not); + $this->assertSame($c1, $c1->not->article(null)); + $this->assertSame($c2, $c2->not->article(null)); + } } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index f35e21e..664db4e 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -13,7 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index bf35a30..91b370c 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -14,7 +14,7 @@ use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; @@ -1809,6 +1809,8 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"], ]; $in2 = [ // simple context tests @@ -1833,6 +1835,7 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"], ]; $in3 = [ // time-based context tests @@ -1868,6 +1871,7 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1890,6 +1894,7 @@ LONG_STRING; $this->outputHeadlines(15), $this->outputHeadlines(11), // defaulting sorting is not fully implemented $this->outputHeadlines(16), + $this->outputHeadlines(17), ]; $out3 = [ $this->outputHeadlines(1001), diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php new file mode 100644 index 0000000..62ad553 --- /dev/null +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -0,0 +1,126 @@ + ["", new Context], + 'Whitespace only' => [" \n \t", new Context], + 'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])], + 'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])], + 'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])], + 'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])], + 'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])], + 'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])], + 'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])], + 'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])], + 'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])], + 'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])], + 'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])], + 'Empty quoted token' => ['""', new Context], + 'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])], + 'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])], + 'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])], + 'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])], + 'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])], + 'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])], + 'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])], + 'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])], + 'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])], + 'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])], + 'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])], + 'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])], + 'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)], + 'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)], + 'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)], + 'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)], + 'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)], + 'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)], + 'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)], + 'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)], + 'Bare star tag true' => ['STAR:true', (new Context)->starred(true)], + 'Bare star tag false' => ['STAR:false', (new Context)->starred(false)], + 'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)], + 'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)], + 'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)], + 'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)], + 'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)], + 'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)], + 'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)], + 'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)], + 'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)], + 'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)], + 'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)], + 'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)], + 'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)], + 'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)], + 'Bare pub tag true' => ['PUB:true', null], + 'Bare pub tag false' => ['PUB:false', new Context], + 'Bare negative pub tag true' => ['-pub:true', new Context], + 'Bare negative pub tag false' => ['-pub:false', null], + 'Quoted pub tag true' => ['"PUB:true"', null], + 'Quoted pub tag false' => ['"PUB:false"', new Context], + 'Quoted negative pub tag true' => ['"-pub:true"', new Context], + 'Quoted negative pub tag false' => ['"-pub:false"', null], + 'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])], + 'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])], + 'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])], + 'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])], + 'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])], + 'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])], + 'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])], + 'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])], + 'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])], + 'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])], + 'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])], + 'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])], + 'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])], + 'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])], + 'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])], + 'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])], + 'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])], + 'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])], + 'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])], + 'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])], + 'Contradictory booleans' => ['unread:true -unread:true', null], + '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")], + '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)->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")], + ]; + } + + /** @dataProvider provideSearchStrings */ + public function testApplySearchToContext(string $search, $exp) { + $act = Search::parse($search); + //var_export($act); + $this->assertEquals($exp, $act); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 65a0893..aac033b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -99,6 +99,7 @@ cases/REST/NextCloudNews/PDO/TestV1_2.php + cases/REST/TinyTinyRSS/TestSearch.php cases/REST/TinyTinyRSS/TestAPI.php cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php