diff --git a/lib/Database.php b/lib/Database.php index 5935a1f8..13587f6f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -2,7 +2,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; -use JKingWeb\Arsse\Database\Query; +use JKingWeb\Arsse\Misc\Query; +use JKingWeb\Arsse\Misc\Context; class Database { @@ -86,20 +87,7 @@ class Database { } public function settingGet(string $key) { - $row = $this->db->prepare("SELECT value, type from arsse_settings where key = ?", "str")->run($key)->getRow(); - if(!$row) return null; - switch($row['type']) { - case "int": return (int) $row['value']; - case "numeric": return (float) $row['value']; - case "text": return $row['value']; - case "json": return json_decode($row['value']); - case "timestamp": return date_create_from_format("!".self::FORMAT_TS, $row['value'], new DateTimeZone("UTC")); - case "date": return date_create_from_format("!".self::FORMAT_DATE, $row['value'], new DateTimeZone("UTC")); - case "time": return date_create_from_format("!".self::FORMAT_TIME, $row['value'], new DateTimeZone("UTC")); - case "bool": return (bool) $row['value']; - case "null": return null; - default: return $row['value']; - } + return $this->db->prepare("SELECT value from arsse_settings where key is ?", "str")->run($key)->getValue(); } public function begin(): Db\Transaction { @@ -590,30 +578,46 @@ class Database { return $this->db->prepare("SELECT count(*) from arsse_marks where owner is ? and starred is 1", "str")->run($user)->getValue(); } - public function editionLatest(string $user, array $context = []): int { + public function editionLatest(string $user, Context $context = null): int { if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); - if(array_key_exists("subscription", $context)) { - $id = $context['subscription']; - $sub = $this->subscriptionValidateId($user, $id); - return (int) $this->db->prepare( - "SELECT max(arsse_editions.id) - from arsse_editions - left join arsse_articles on article is arsse_articles.id - left join arsse_feeds on arsse_articles.feed is arsse_feeds.id - where arsse_feeds.id is ?", - "int" - )->run($sub['feed'])->getValue(); + if(!$context) $context = new Context; + $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article is arsse_articles.id left join arsse_feeds on arsse_articles.feed is arsse_feeds.id"); + if($context->subscription()) { + // if a subscription is specified, make sure it exists + $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; + // a simple WHERE clause is required here + $q->setWhere("arsse_feeds.id is ?", "int", $id); + } else { + $q->setCTE("user(user) as (SELECT ?)", "str", $user); + if($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) as (SELECT ? union select id from arsse_folders join folders on parent is folder)", "int", $context->folder); + // add another CTE for the subscriptions within the folder + $q->setCTE( + "feeds(feed) as (SELECT feed from arsse_subscriptions join user on user is owner join folders on arsse_subscription.folder is folders.folder)", + [], // binding types + [], // binding values + "join feeds on arsse_articles.feed is feeds.feed" // join expression + ); + } else { + // if no folder is specified, a single CTE is added + $q->setCTE( + "feeds(feed) as (SELECT feed from arsse_subscriptions join user on user is owner)", + [], // binding types + [], // binding values + "join feeds on arsse_articles.feed is feeds.feed" // join expression + ); + } } - return (int) $this->db->prepare("SELECT max(id) from arsse_editions")->run()->getValue(); // FIXME: this is incorrect; it's not restricted to the user's subscriptions + return (int) $this->db->prepare($q)->run()->getValue(); } - public function articleList(string $user): Db\Result { + public function articleList(string $user, Context $context = null): Db\Result { if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); - return $this->db->prepare( - "WITH - user(user) as (SELECT ?), - subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner) - ". + if(!$context) $context = new Context; + $q = new Query( "SELECT arsse_articles.id, arsse_articles.url, @@ -634,7 +638,124 @@ class Database { join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id ", - "str","str","str" - )-run($user, $this->dateFormatDefault, $this->dateFormatDefault, $this->dateFormatDefault); + "", // WHERE clause + "latestEdition".(!$context->reverse ? " desc" : ""), // ORDER BY clause + $context->limit, + $context->offset + ); + $q->setCTE("user(user) as (SELECT ?)", "str", $user); + if($context->subscription()) { + // if a subscription is specified, make sure it exists + $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; + // add a basic CTE that will join in only the requested subscription + $q->setCTE("subscribed_feeds(id) as (SELECT ?)", "int", $id); + } else if($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) as (SELECT ? union select id from arsse_folders join folders on parent is folder)", "int", $context->folder); + // add another CTE for the subscriptions within the folder + $q->setCTE("subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner join folders on arsse_subscription.folder is folders.folder)"); + } else { + // otherwise add a CTE for all the user's subscriptions + $q->setCTE("subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner)"); + } + // filter based on edition offset + if($context->oldestEdition()) { + $q->setWhere("latestEdition >= ?", "int", $context->oldestEdition); + } else if($context->latestEdition()) { + $q->setWhere("latestEdition <= ?", "int", $context->oldestEdition); + } + // filter based on lastmod time + if($context->modifiedSince()) $q->setWhere("modified >= ?", "datetime", $context->modifiedSince); + // filter for un/read and un/starred status if specified + if($context->unread()) $q->setWhere("unread is ?", "bool", $context->unread); + if($context->starred()) $q->setWhere("starred is ?", "bool", $context->starred); + // perform the query and return results + return $this->db->prepare($q, "str", "str", "str")-run($this->dateFormatDefault, $this->dateFormatDefault, $this->dateFormatDefault); + } + + public function articlePropertiesSet(string $user, array $data, Context $context = null): bool { + if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + if(!$context) $context = new Context; + // sanitize input + $valid = [ + 'read' => "strict bool", + 'starred' => "strict bool", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + $insValues = [ + isset($data['read']) ? $data['read'] : false, + isset($data['starred']) ? $data['starred'] : false, + ]; + // the two queries we want to execute to make the requested changes + $queries = [ + [ + 'body' => "UPDATE arsse_marks set $setClause, modified = CURRENT_TIMESTAMP", + 'where' => "owner is ? and article in (select id from target_articles and exists is 1)", + 'types' => [$setTypes, "str"], + 'values' => [$setValues, $user] + ], + [ + 'body' => "INSERT INTO arsse_marks(article,owner,read,starred) SELECT id, ?, ?, ? from target_articles", + 'where' => "exists is 0", + 'types' => ["str", "strict bool", "strict bool"], + 'values' => [$user, $insValues] + ] + ]; + $out = 0; + // wrap this UPDATE and INSERT together into a transaction + $tr = $this->begin(); + // execute each query in sequence + foreach($queries as $query) { + // first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles + $q = new Query( + "SELECT + arsse_articles.id as id, + max(arsse_editions.id) as latestEdition + (select count(*) from arsse_marks join user on user is owner where article is arsse_articles.id) as exists + FROM arsse_articles + join subscribed_feeds on feed is subscribed_feeds.id + join arsse_editions on arsse_articles.id is arsse_editions.article + " + ); + $q->setCTE("user(user) as (SELECT ?)", "str", $user); + if($context->subscription()) { + // if a subscription is specified, make sure it exists + $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; + // add a basic CTE that will join in only the requested subscription + $q->setCTE("subscribed_feeds(id) as (SELECT ?)", "int", $id); + } else if($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) as (SELECT ? union select id from arsse_folders join folders on parent is folder)", "int", $context->folder); + // add another CTE for the subscriptions within the folder + $q->setCTE("subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner join folders on arsse_subscription.folder is folders.folder)"); + } else { + // otherwise add a CTE for all the user's subscriptions + $q->setCTE("subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner)"); + } + // filter for specific article or edition + if($context->article()) $q->setWhere("arsse_article.id is ?", "int", $context->article); + if($context->edition()) $q->setWhere("arsse_article.id is (SELECT article from arsse_editions where id is ?)", "int", $context->edition); + // filter for un/read and un/starred status if specified + if($context->unread()) $q->setWhere("unread is ?", "bool", $context->unread); + if($context->starred()) $q->setWhere("starred is ?", "bool", $context->starred); + // filter based on lastmod time + if($context->modifiedSince()) $q->setWhere("modified >= ?", "datetime", $context->modifiedSince); + // push the current query onto the CTE stack and execute the query we're actually interested in + $q->pushCTE( + "target_articles(id, exists)", // CTE table specification + [], // CTE types + [], // CTE values + $query['body'], // new query body + $query['where'] // new query WHERE clause + ); + $out += $this->db->prepare($q, $query['types'])->run($query['values'])->changes(); + } + // commit the transaction + $tr->commit(); + return (bool) $out; } } \ No newline at end of file diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 71255317..d1583bd6 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -126,7 +126,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { } public function prepareArray($query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { - if($query instanceof \JKingWeb\Arsse\Database\Query) { + if($query instanceof \JKingWeb\Arsse\Misc\Query) { $preValues = $query->getCTEValues(); $postValues = $query->getWhereValues(); $paramTypes = [$query->getCTETypes(), $paramTypes, $query->getWhereTypes()]; diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php new file mode 100644 index 00000000..babf8221 --- /dev/null +++ b/lib/Misc/Context.php @@ -0,0 +1,87 @@ +props[$prop] = true; + $this->$prop = $value; + return $this; + } else { + return isset($this->props[$prop]); + } + } + + function reverse(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function limit(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function offset(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function folder(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function subscription(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function unread(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function starred(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function modifiedSince($spec = null) { + $spec = $this->dateNormalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function notModifiedSince($spec = null) { + $spec = $this->dateNormalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function edition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function article(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} \ No newline at end of file diff --git a/lib/Database/Query.php b/lib/Misc/Query.php similarity index 69% rename from lib/Database/Query.php rename to lib/Misc/Query.php index 92faa98f..7f8bb0c5 100644 --- a/lib/Database/Query.php +++ b/lib/Misc/Query.php @@ -1,6 +1,6 @@ qCTE); + function getQuery(): string { $out = ""; - if($cte) { + if(sizeof($this->qCTE)) { // start with common table expressions $out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." "; } // add the body + $out .= $this->buildQueryBody(); + return $out; + } + + function pushCTE(string $tableSpec, $types, $values, string $body, string $where = "", string $order = "", int $limit = 0, int $offset = 0): 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 and ORDER BY parts belong to the new CTE and are removed from the main query + $b = $this->buildQueryBody(); + array_push($types, $this->getWhereTypes()); + array_push($values, $this->getWhereValues()); + if($this->limit) { + array_push($types, "strict int"); + array_push($values, $this->limit); + } + if($this->offset) { + array_push($types, "strict int"); + array_push($values, $this->offset); + } + $this->setCTE($tableSpec." as (".$this->buildQueryBody().")", $types, $value); + $this->tWhere = []; + $this->vWhere = []; + $this->order = []; + $this->__construct($body, $where, $order, $limit, $offset); + return true; + } + + function getWhereTypes(): array { + return $this->tWhere; + } + + function getWhereValues(): array { + return $this->vWhere; + } + + function getCTETypes(): array { + return $this->tCTE; + } + + function getCTEValues(): array { + return $this->vCTE; + } + + protected function buildQueryBody(): string { + $out = ""; + // add the body $out .= $this->body; - if($cte) { + if(sizeof($this->qCTE)) { // add any joins against CTEs $out .= " ".implode(" ", $this->jCTE); } @@ -85,20 +129,4 @@ class Query { } return $out; } - - function getWhereTypes(): array { - return $this->tWhere; - } - - function getWhereValues(): array { - return $this->vWhere; - } - - function getCTETypes(): array { - return $this->tCTE; - } - - function getCTEValues(): array { - return $this->vCTE; - } } \ No newline at end of file diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index ccff17fa..f230b540 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; use JKingWeb\Arsse\Data; use JKingWeb\Arsse\User; +use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; @@ -281,7 +282,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $feed = Data::$db->subscriptionPropertiesGet(Data::$user->id, $id); $feed = $this->feedTranslate($feed); $out = ['feeds' => [$feed]]; - $newest = Data::$db->editionLatest(Data::$user->id, ['subscription' => $id]); + $newest = Data::$db->editionLatest(Data::$user->id, (new Context)->subscription($id)); if($newest) $out['newestItemId'] = $newest; return new Response(200, $out); } diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php new file mode 100644 index 00000000..5fd09ae0 --- /dev/null +++ b/tests/Misc/TestContext.php @@ -0,0 +1,51 @@ +getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { + if($m->isConstructor() || $m->isStatic()) continue; + $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"); + } + } + + function testSetContextOptions() { + $v = [ + 'reverse' => true, + 'limit' => 10, + 'offset' => 5, + 'folder' => 42, + 'subscription' => 2112, + 'article' => 255, + 'edition' => 65535, + 'latestEdition' => 47, + 'oldestEdition' => 1337, + 'unread' => true, + 'starred' => true, + 'modifiedSince' => new \DateTime(), + 'notModifiedSince' => new \DateTime(), + ]; + $times = ['modifiedSince','notModifiedSince']; + $c = new Context; + foreach((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { + if($m->isConstructor() || $m->isStatic()) 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]); + } else { + $this->assertSame($c->$method, $v[$method]); + } + } + } +} \ No newline at end of file diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 7e85a4a4..ff427e3d 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -4,6 +4,7 @@ namespace JKingWeb\Arsse; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\Test\Result; +use JKingWeb\Arsse\Misc\Context; use Phake; @@ -275,8 +276,8 @@ class TestNCNV1_2 extends \PHPUnit\Framework\TestCase { Phake::when(Data::$db)->subscriptionAdd(Data::$user->id, "http://example.org/news.atom")->thenReturn( 42 )->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("constraintViolation")); // error on the second call Phake::when(Data::$db)->subscriptionPropertiesGet(Data::$user->id, 2112)->thenReturn($this->feeds['db'][0]); Phake::when(Data::$db)->subscriptionPropertiesGet(Data::$user->id, 42)->thenReturn($this->feeds['db'][1]); - Phake::when(Data::$db)->editionLatest(Data::$user->id, ['subscription' => 2112])->thenReturn(0); - Phake::when(Data::$db)->editionLatest(Data::$user->id, ['subscription' => 42])->thenReturn(4758915); + Phake::when(Data::$db)->editionLatest(Data::$user->id, (new Context)->subscription(2112))->thenReturn(0); + Phake::when(Data::$db)->editionLatest(Data::$user->id, (new Context)->subscription( 42))->thenReturn(4758915); Phake::when(Data::$db)->subscriptionPropertiesSet(Data::$user->id, 2112, ['folder' => 3])->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("idMissing")); // folder ID 3 does not exist Phake::when(Data::$db)->subscriptionPropertiesSet(Data::$user->id, 42, ['folder' => 8])->thenReturn(true); // set up a mock for a bad feed diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 7366d9bc..62886791 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -32,6 +32,9 @@ Feed/TestFeedFetching.php Feed/TestFeed.php + + Misc/TestContext.php + Db/SQLite3/TestDbResultSQLite3.php Db/SQLite3/TestDbStatementSQLite3.php