From bc3182a961959485867c154459d9f9554f716cf5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 18:50:39 -0500 Subject: [PATCH] Basic substring searching --- lib/Database.php | 42 +++++++++++++++++++++++++- tests/cases/Database/SeriesArticle.php | 19 ++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index dfa9e7c..30562d9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,6 +39,8 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; + /** The maximum number of search terms allowed; this is a hard limit */ + const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -149,6 +151,35 @@ 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 + */ + protected function generateSearch(array $terms, array $cols): 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).")"; + } + $clause = "(".implode(" and ", $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(); @@ -1160,7 +1191,7 @@ class Database { 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 multiple specific articles have been requested, filter against the list 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) { @@ -1221,6 +1252,15 @@ class Database { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } + // filter based on search terms + if ($context->searchTerms()) { + if (!$context->searchTerms) { + throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { + throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => __FUNCTION__, 'max' => self::LIMIT_TERMS]); + } + $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); + } // return the query return $q; } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index c3c4425..d19f85b 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -111,9 +111,9 @@ 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"], + [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -494,6 +494,9 @@ trait SeriesArticle { // get specific starred articles $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); + // get items that match search terms + $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); + $compareIds([1], (new Context)->searchTerms(["one", "first"])); } public function testListArticlesOfAMissingFolder() { @@ -985,4 +988,14 @@ 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))); + } }