Browse Source

Added context options to mark multiple specific articles or editions

This required significant rewrites of queries because of stale editions
microsub
J. King 7 years ago
parent
commit
761c1054f3
  1. 57
      lib/Database.php
  2. 38
      lib/Misc/Context.php
  3. 6
      locale/en.php
  4. 12
      tests/Misc/TestContext.php
  5. 85
      tests/lib/Database/SeriesArticle.php

57
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

38
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);
}
}

6
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',

12
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");
}
}
}

85
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);

Loading…
Cancel
Save