diff --git a/lib/Database.php b/lib/Database.php index f083e9c..856b607 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -703,6 +703,16 @@ class Database { $out = 0; // wrap this UPDATE and INSERT together into a transaction $tr = $this->begin(); + // if an edition context is specified, make sure it's valid + if($context->edition()) { + // make sure the edition exists + $edition = $this->articleValidateEdition($user, $context->edition); + // if the edition is not the latest, make no marks and return + if(!$edition['current']) return false; + } else if($context->article()) { + // otherwise if an article context is specified, make sure it's valid + $this->articleValidateId($user, $context->article); + } // 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 @@ -727,33 +737,32 @@ class Database { or starred is not coalesce((select starred from target_values),starred) ) ) as to_update - FROM arsse_articles - join subscribed_feeds on feed is subscribed_feeds.id - " + FROM arsse_articles" ); // common table expression for the affected user $q->setCTE("user(user) as (SELECT ?)", "str", $user); // common table expression with the values to set $q->setCTE("target_values(read,starred) as (select ?,?)", ["bool","bool"], $values); - if($context->subscription()) { + if($context->edition()) { + $q->setWhere("arsse_articles.id is ?", "int", $edition['article']); + } else if($context->article()) { + $q->setWhere("arsse_articles.id is ?", "int", $context->article); + } else 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,sub) as (SELECT ?,?)", ["int","int"], [$id,$context->subscription]); + $q->setCTE("subscribed_feeds(id,sub) as (SELECT ?,?)", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.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,sub) as (SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder)"); + $q->setCTE("subscribed_feeds(id,sub) as (SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder)", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); } else { // otherwise add a CTE for all the user's subscriptions - $q->setCTE("subscribed_feeds(id,sub) as (SELECT feed,id from arsse_subscriptions join user on user is owner)"); + $q->setCTE("subscribed_feeds(id,sub) as (SELECT feed,id from arsse_subscriptions join user on user is owner)", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); } - // 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 based on edition offset if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition); if($context->latestEdition()) $q->setWhere("edition <= ?", "int", $context->latestEdition); @@ -773,4 +782,38 @@ class Database { $tr->commit(); return (bool) $out; } + + public function articleValidateId(string $user, int $id): array { + $out = $this->db->prepare( + "SELECT + arsse_articles.id as article, + (select max(id) from arsse_editions where article is arsse_articles.id) as edition + FROM arsse_articles + join arsse_feeds on arsse_feeds.id is arsse_articles.feed + join arsse_subscriptions on arsse_subscriptions.feed is arsse_feeds.id + WHERE + arsse_articles.id is ? and arsse_subscriptions.owner is ?", + "int", "str" + )->run($id, $user)->getRow(); + if(!$out) throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "article", 'id' => $id]); + return $out; + } + + public function articleValidateEdition(string $user, int $id): array { + $out = $this->db->prepare( + "SELECT + arsse_editions.id as edition, + arsse_editions.article as article, + (arsse_editions.id is (select max(id) from arsse_editions where article is arsse_editions.article)) as current + FROM arsse_editions + join arsse_articles on arsse_editions.article is arsse_articles.id + join arsse_feeds on arsse_feeds.id is arsse_articles.feed + join arsse_subscriptions on arsse_subscriptions.feed is arsse_feeds.id + WHERE + edition is ? and arsse_subscriptions.owner is ?", + "int", "str" + )->run($id, $user)->getRow(); + if(!$out) throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]); + return $out; + } } \ No newline at end of file diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 870aad1..32edc83 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -81,6 +81,7 @@ class Query { array_push($values, $this->offset); } $this->setCTE($tableSpec." as (".$this->buildQueryBody().")", $types, $values); + $this->jCTE = []; $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index b635eb5..9b2a507 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -83,7 +83,7 @@ trait SeriesArticle { [8,"john.doe@example.org",11,null], [9,"john.doe@example.org",12,null], [10,"john.doe@example.org",13,null], - [11,"john.doe@example.net",1,null], + [11,"john.doe@example.net",10,null], [12,"john.doe@example.net",2,9], [13,"john.doe@example.net",3,8], [14,"john.doe@example.net",4,7], @@ -163,11 +163,10 @@ trait SeriesArticle { ["john.doe@example.org",103,0,1,'2000-01-03 03:00:00'], ["john.doe@example.org",104,1,1,'2000-01-04 04:00:00'], ["john.doe@example.org",105,0,0,'2000-01-05 05:00:00'], - ["john.doe@example.net", 1,0,0,'2017-01-01 00:00:00'], - ["john.doe@example.net", 2,1,0,'2017-01-01 00:00:00'], + ["john.doe@example.net", 19,0,0,'2017-01-01 00:00:00'], + ["john.doe@example.net", 20,1,0,'2017-01-01 00:00:00'], ["john.doe@example.net", 3,0,1,'2017-01-01 00:00:00'], ["john.doe@example.net", 4,1,1,'2017-01-01 00:00:00'], - ] ], ]; @@ -422,6 +421,131 @@ trait SeriesArticle { $this->compareExpectations($state); } + function testMarkAllArticlesUnreadAndStarred() { + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true]); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][8][3] = 1; + $state['arsse_marks']['rows'][8][4] = $now; + $state['arsse_marks']['rows'][9][2] = 0; + $state['arsse_marks']['rows'][9][3] = 1; + $state['arsse_marks']['rows'][9][4] = $now; + $state['arsse_marks']['rows'][11][2] = 0; + $state['arsse_marks']['rows'][11][4] = $now; + $state['arsse_marks']['rows'][] = [$this->user,5,0,1,$now]; + $state['arsse_marks']['rows'][] = [$this->user,6,0,1,$now]; + $state['arsse_marks']['rows'][] = [$this->user,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [$this->user,8,0,1,$now]; + $this->compareExpectations($state); + } + + function testMarkAllArticlesReadAndUnstarred() { + Data::$db->articleMark($this->user, ['read'=>true,'starred'=>false]); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][8][2] = 1; + $state['arsse_marks']['rows'][8][4] = $now; + $state['arsse_marks']['rows'][10][2] = 1; + $state['arsse_marks']['rows'][10][3] = 0; + $state['arsse_marks']['rows'][10][4] = $now; + $state['arsse_marks']['rows'][11][3] = 0; + $state['arsse_marks']['rows'][11][4] = $now; + $state['arsse_marks']['rows'][] = [$this->user,5,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,6,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,7,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,8,1,0,$now]; + $this->compareExpectations($state); + } + + function testMarkATreeFolder() { + Data::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(7)); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][] = [$this->user,5,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,6,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,7,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,8,1,0,$now]; + $this->compareExpectations($state); + } + + function testMarkALeafFolder() { + Data::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(8)); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][] = [$this->user,5,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,6,1,0,$now]; + $this->compareExpectations($state); + } + + function testMarkAMissingFolder() { + $this->assertException("idMissing", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(42)); + } + + function testMarkASubscription() { + Data::$db->articleMark($this->user, ['read'=>true], (new Context)->subscription(13)); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][] = [$this->user,5,1,0,$now]; + $state['arsse_marks']['rows'][] = [$this->user,6,1,0,$now]; + $this->compareExpectations($state); + } + + function testMarkAMissingSubscription() { + $this->assertException("idMissing", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(2112)); + } + + function testMarkAnArticle() { + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(20)); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][9][3] = 1; + $state['arsse_marks']['rows'][9][4] = $now; + $this->compareExpectations($state); + } + + function testMarkAMissingArticle() { + $this->assertException("idMissing", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(1)); + } + + function testMarkAnEdition() { + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(1001)); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $state['arsse_marks']['rows'][9][3] = 1; + $state['arsse_marks']['rows'][9][4] = $now; + $this->compareExpectations($state); + } + + function testMarkAStaleEdition() { + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(20)); // no changes occur + $state = $this->primeExpectations($this->data, [ + 'arsse_marks' => ["owner","article","read","starred","modified"], + ]); + $this->compareExpectations($state); + } + + function testMarkAMissingEdition() { + $this->assertException("idMissing", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(2)); + } + protected function compareIds(array $exp, Context $c) { $ids = array_column($ids = Data::$db->articleList($this->user, $c)->getAll(), "id"); sort($ids);