diff --git a/lib/Database.php b/lib/Database.php index f297675..152a099 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -681,19 +681,19 @@ class Database { $queries = [ "UPDATE arsse_marks set - read = coalesce((select read from target_values),read), + read = case when (select honour_read from target_articles where target_articles.id is article) is 1 then (select read from target_values) else read end, starred = coalesce((select starred from target_values),starred), modified = CURRENT_TIMESTAMP WHERE owner is (select user from user) - and article in (select id from target_articles where to_update is 1)", + and article in (select id from target_articles where to_insert is 0 and (honour_read is 1 or honour_star is 1))", "INSERT INTO arsse_marks(owner,article,read,starred) select (select user from user), id, - coalesce((select read from target_values),0), + coalesce((select read from target_values) * honour_read,0), coalesce((select starred from target_values),0) - from target_articles where to_insert is 1" + from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1)" ]; $out = 0; // wrap this UPDATE and INSERT together into a transaction @@ -712,26 +712,15 @@ class Database { 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 + "SELECT arsse_articles.id as id, (select max(id) from arsse_editions where article is arsse_articles.id) as edition, - max(arsse_articles.modified, + max(arsse_articles.modified, coalesce((select modified from arsse_marks join user on user is owner where article is arsse_articles.id),'') ) as modified_date, - ( - not exists(select id from arsse_marks join user on user is owner where article is arsse_articles.id) - and exists(select * from target_values where read is 1 or starred is 1) - ) as to_insert, - exists( - select id from arsse_marks - join user on user is owner - where - article is arsse_articles.id - and ( - read is not coalesce((select read from target_values),read) - or starred is not coalesce((select starred from target_values),starred) - ) - ) as to_update + (not exists(select id from arsse_marks join user on user is owner where article is arsse_articles.id)) as to_insert, + ((select read from target_values) is not null and (select read from target_values) is not (coalesce((select read from arsse_marks join user on user is owner where article is arsse_articles.id),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article is arsse_articles.id) in (select edition from requested_articles))) as honour_read, + ((select starred from target_values) is not null and (select starred from target_values) is not (coalesce((select starred from arsse_marks join user on user is owner where article is arsse_articles.id),0))) as honour_star FROM arsse_articles" ); // common table expression for the affected user @@ -760,6 +749,32 @@ class Database { // 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)", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); } + if($context->editions()) { + // if multiple specific editions have been requested, prepare a CTE to list them and their articles + if(!$context->editions) throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + if(sizeof($context->editions) > 50) throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements + list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); + $q->setCTE( + "requested_articles(id,edition) as (select article,id as edition from arsse_editions where edition in ($inParams))", + $inTypes, + $context->editions + ); + $q->setWhere("arsse_articles.id in (select id from requested_articles)"); + } else if($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 + if(sizeof($context->articles) > 50) throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements + list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); + $q->setCTE( + "requested_articles(id,edition) as (select id,(select max(id) from arsse_editions where article is arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams))", + $inTypes, + $context->articles + ); + $q->setWhere("arsse_articles.id in (select id from requested_articles)"); + } else { + // if neither list is specified, mock an empty table + $q->setCTE("requested_articles(id,edition) as (select 'empty','table' where 1 is 0)"); + } // filter based on edition offset if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition); if($context->latestEdition()) $q->setWhere("edition <= ?", "int", $context->latestEdition); @@ -768,7 +783,7 @@ class Database { if($context->notModifiedSince()) $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); // push the current query onto the CTE stack and execute the query we're actually interested in $q->pushCTE( - "target_articles(id,edition,modified,to_insert,to_update)", // CTE table specification + "target_articles(id,edition,modified_date,to_insert,honour_read,honour_star)", // CTE table specification [], // CTE types [], // CTE values $query // new query body diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index babf822..41a490c 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -18,6 +18,8 @@ class Context { public $notModifiedSince; public $edition; public $article; + public $editions; + public $articles; protected $props = []; @@ -30,6 +32,32 @@ class Context { return isset($this->props[$prop]); } } + + protected function cleanArray(array $spec): array { + $spec = array_values($spec); + for($a = 0; $a < sizeof($spec); $a++) { + $id = $spec[$a]; + if(is_int($id) && $id > -1) continue; + if(is_float($id) && !fmod($id, 1) && $id >= 0) { + $spec[$a] = (int) $id; + continue; + } + if(is_string($id)) { + try { + $ch1 = strval(intval($id)); + $ch2 = strval($id); + } catch(\Throwable $e) { + $ch1 = true; + $ch2 = false; + } + if($ch1 !== $ch2 || $id < 1) $id = 0; + } else { + $id = 0; + } + $spec[$a] = (int) $id; + } + return array_values(array_filter($spec)); + } function reverse(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); @@ -84,4 +112,14 @@ class Context { function article(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + + function editions(array $spec = null) { + if($spec) $spec = $this->cleanArray($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + function articles(array $spec = null) { + if($spec) $spec = $this->cleanArray($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } \ No newline at end of file diff --git a/locale/en.php b/locale/en.php index bc0829e..3fe2019 100644 --- a/locale/en.php +++ b/locale/en.php @@ -63,9 +63,9 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.engineErrorGeneral' => '{0}', 'Exception.JKingWeb/Arsse/Db/Exception.unknownSavepointStatus' => 'Savepoint status code {0} not implemented', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', - 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Required field "{field}" of action "{action}" may not contain only whitespace', - 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Required field "{field}" of action "{action}" has a maximum length of {max}', - 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooShort' => 'Required field "{field}" of action "{action}" has a minimum length of {min}', + 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', + 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', + 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooShort' => 'Field "{field}" of action "{action}" has a minimum length of {min}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.subjectMissing' => 'Referenced ID ({id}) in field "{field}" does not exist', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.idMissing' => 'Referenced ID ({id}) in field "{field}" does not exist', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.circularDependence' => 'Referenced ID ({id}) in field "{field}" creates a circular dependence', diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index 5fd09ae..9eb70b2 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -32,6 +32,8 @@ class TestContext extends \PHPUnit\Framework\TestCase { 'starred' => true, 'modifiedSince' => new \DateTime(), 'notModifiedSince' => new \DateTime(), + 'editions' => [1,2], + 'articles' => [1,2], ]; $times = ['modifiedSince','notModifiedSince']; $c = new Context; @@ -48,4 +50,14 @@ class TestContext extends \PHPUnit\Framework\TestCase { } } } + + function testCleanArrayValues() { + $methods = ["articles", "editions"]; + $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1,2, 3]; + $c = new Context; + foreach($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } } \ No newline at end of file diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 78d4cce..7d58561 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -518,6 +518,39 @@ trait SeriesArticle { $this->compareExpectations($state); } + function testMarkMultipleArticles() { + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->articles([2,4,7,20])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $state['arsse_marks']['rows'][9][3] = 1; + $state['arsse_marks']['rows'][9][4] = $now; + $state['arsse_marks']['rows'][] = [$this->user,7,0,1,$now]; + $this->compareExpectations($state); + } + + function testMarkMultipleArticlessUnreadAndStarred() { + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([2,4,7,20])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $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,7,0,1,$now]; + $this->compareExpectations($state); + } + + function testMarkTooFewMultipleArticles() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); + } + + function testMarkTooManyMultipleArticles() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1,51))); + } + function testMarkAMissingArticle() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(1)); @@ -532,6 +565,58 @@ trait SeriesArticle { $this->compareExpectations($state); } + function testMarkMultipleEditions() { + Data::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([2,4,7,20])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $state['arsse_marks']['rows'][9][3] = 1; + $state['arsse_marks']['rows'][9][4] = $now; + $state['arsse_marks']['rows'][] = [$this->user,7,0,1,$now]; + $this->compareExpectations($state); + } + + function testMarkMultipleEditionsUnread() { + Data::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,1001])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $state['arsse_marks']['rows'][9][2] = 0; + $state['arsse_marks']['rows'][9][4] = $now; + $state['arsse_marks']['rows'][11][2] = 0; + $state['arsse_marks']['rows'][11][4] = $now; + $this->compareExpectations($state); + } + + function testMarkMultipleEditionsUnreadWithStale() { + Data::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,20])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $state['arsse_marks']['rows'][11][2] = 0; + $state['arsse_marks']['rows'][11][4] = $now; + $this->compareExpectations($state); + } + + function testMarkMultipleEditionsUnreadAndStarredWithStale() { + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([2,4,7,20])); + $now = $this->dateTransform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $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,7,0,1,$now]; + $this->compareExpectations($state); + } + + function testMarkTooFewMultipleEditions() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); + } + + function testMarkTooManyMultipleEditions() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Data::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1,51))); + } + function testMarkAStaleEditionUnread() { Data::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables);