Browse Source

Allow for replacing label and tag associations

This supplements adding and removing
microsub
J. King 5 years ago
parent
commit
3899ee6b4e
  1. 141
      lib/Database.php
  2. 3
      lib/REST/TinyTinyRSS/API.php
  3. 37
      tests/cases/Database/SeriesLabel.php
  4. 45
      tests/cases/Database/SeriesTag.php
  5. 16
      tests/cases/REST/TinyTinyRSS/TestAPI.php

141
lib/Database.php

@ -43,6 +43,12 @@ class Database {
const LIMIT_SET_SIZE = 25;
/** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */
const LIMIT_SET_STRING_LENGTH = 200;
/** Makes tag/label association change operations remove members */
const ASSOC_REMOVE = 0;
/** Makes tag/label association change operations add members */
const ASSOC_ADD = 1;
/** Makes tag/label association change operations replace members */
const ASSOC_REPLACE = 2;
/** A map database driver short-names and their associated class names */
const DRIVER_NAMES = [
'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class,
@ -1955,37 +1961,61 @@ class Database {
* @param string $user The owner of the label
* @param integer|string $id The numeric identifier or name of the label
* @param Context $context The query context matching the desired articles
* @param boolean $remove Whether to remove (true) rather than add (true) an association with the articles matching the context
* @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int {
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) {
throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore
}
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// validate the label ID, and get the numeric ID if matching by name
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];
$context = $context ?? new Context;
// prepare either one or two queries
// first update any existing entries with the removal or re-addition of their association
$q1 = $this->articleQuery($user, $context);
$q1->pushCTE("target_articles");
$q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]);
$v1 = $q1->getValues();
$q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes());
// next, if we're not removing, add any new entries that need to be added
if (!$remove) {
$q2 = $this->articleQuery($user, $context, ["id", "subscription"]);
$q2->pushCTE("target_articles");
$q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]);
$v2 = $q2->getValues();
$q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes());
// get the list of articles matching the context
$articles = iterator_to_array($this->articleList($user, $context ?? new Context));
// an empty article list is a special case
if (!sizeof($articles)) {
if ($mode == self::ASSOC_REPLACE) {
// replacing with an empty set means setting everything to zero
return $this->db->prepare("UPDATE arsse_label_members set assigned = 0, modified = CURRENT_TIMESTAMP where label = ? and assigned = 1", "int")->run($id)->changes();
} else {
// adding or removing is a no-op
return 0;
}
} else {
$articles = array_column($articles, "id");
}
// prepare up to three queries: removing requires one, adding two, and replacing three
list($inClause, $inTypes, $inValues) = $this->generateIn($articles, "int");
$updateQ = "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article %in% ($inClause)";
$updateT = ["bool", "int", "bool", $inTypes];
$insertQ = "INSERT INTO arsse_label_members(label,article,subscription) SELECT ?,a.id,s.id from arsse_articles as a join arsse_subscriptions as s on a.feed = s.feed where s.owner = ? and a.id not in (select article from arsse_label_members where label = ?) and a.id in ($inClause)";
$insertT = ["int", "str", "int", $inTypes];
$clearQ = str_replace("%in%", "not in", $updateQ);
$clearT = $updateT;
$updateQ = str_replace("%in%", "in", $updateQ);
$qList = [];
switch ($mode) {
case self::ASSOC_REMOVE:
$qList[] = [$updateQ, $updateT, [false, $id, false, $inValues]]; // soft-delete any existing associations
break;
case self::ASSOC_ADD:
$qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association
$qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations
break;
case self::ASSOC_REPLACE:
$qList[] = [$clearQ, $clearT, [false, $id, false, $inValues]]; // soft-delete any existing associations for articles not in the list
$qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association
$qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations
break;
}
// execute them in a transaction
$out = 0;
$tr = $this->begin();
$out += $q1->run($v1)->changes();
if (!$remove) {
$out += $q2->run($v2)->changes();
foreach ($qList as list($q, $t, $v)) {
$out += $this->db->prepare($q, ...$t)->run(...$v)->changes();
}
$tr->commit();
return $out;
@ -2235,47 +2265,58 @@ class Database {
*
* @param string $user The owner of the tag
* @param integer|string $id The numeric identifier or name of the tag
* @param integer[] $context The query context matching the desired subscriptions
* @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context
* @param integer[] $subscriptions An array listing the desired subscriptions
* @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the listed associations
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int {
public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int {
if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) {
throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore
}
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->tagValidateId($user, $id, $byName, true)['id'];
// prepare either one or two queries
// an empty subscription list is a special case
if (!sizeof($subscriptions)) {
if ($mode == self::ASSOC_REPLACE) {
// replacing with an empty set means setting everything to zero
return $this->db->prepare("UPDATE arsse_tag_members set assigned = 0, modified = CURRENT_TIMESTAMP where tag = ? and assigned = 1", "int")->run($id)->changes();
} else {
// adding or removing is a no-op
return 0;
}
}
// prepare up to three queries: removing requires one, adding two, and replacing three
list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int");
// first update any existing entries with the removal or re-addition of their association
$q1 = $this->db->prepare(
"UPDATE arsse_tag_members
set assigned = ?, modified = CURRENT_TIMESTAMP
where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))",
"bool",
"int",
"bool",
"str",
$inTypes
);
$v1 = [!$remove, $id, !$remove, $user, $inValues];
// next, if we're not removing, add any new entries that need to be added
if (!$remove) {
$q2 = $this->db->prepare(
"INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)",
"int",
"int",
"str",
$inTypes
);
$v2 = [$id, $id, $user, $inValues];
$updateQ = "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id %in% ($inClause))";
$updateT = ["bool", "int", "bool", "str", $inTypes];
$insertQ = "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)";
$insertT = ["int", "int", "str", $inTypes];
$clearQ = str_replace("%in%", "not in", $updateQ);
$clearT = $updateT;
$updateQ = str_replace("%in%", "in", $updateQ);
$qList = [];
switch ($mode) {
case self::ASSOC_REMOVE:
$qList[] = [$updateQ, $updateT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations
break;
case self::ASSOC_ADD:
$qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association
$qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations
break;
case self::ASSOC_REPLACE:
$qList[] = [$clearQ, $clearT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations for subscriptions not in the list
$qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association
$qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations
break;
}
// execute them in a transaction
$out = 0;
$tr = $this->begin();
$out += $q1->run($v1)->changes();
if (!$remove) {
$out += $q2->run($v2)->changes();
foreach ($qList as list($q, $t, $v)) {
$out += $this->db->prepare($q, ...$t)->run(...$v)->changes();
}
$tr->commit();
return $out;

3
lib/REST/TinyTinyRSS/API.php

@ -1017,6 +1017,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$label = $this->labelIn($data['label_id']);
$articles = explode(",", (string) $data['article_ids']);
$assign = $data['assign'] ?? false;
$assign = $assign ? Database::ASSOC_ADD : Database::ASSOC_REMOVE;
$out = 0;
$in = array_chunk($articles, 50);
for ($a = 0; $a < sizeof($in); $a++) {
@ -1024,7 +1025,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$c = new Context;
$c->articles($in[$a]);
try {
$out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign);
$out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, $assign);
} catch (ExceptionInput $e) {
}
}

37
tests/cases/Database/SeriesLabel.php

@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
use Phake;
@ -490,14 +491,14 @@ trait SeriesLabel {
}
public function testClearALabelFromArticles() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true);
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$this->compareExpectations($state);
}
public function testApplyALabelToArticlesByName() {
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, true);
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), Database::ASSOC_ADD, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][4][3] = 1;
$state['arsse_label_members']['rows'][] = [1,2,1,1];
@ -505,12 +506,42 @@ trait SeriesLabel {
}
public function testClearALabelFromArticlesByName() {
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true);
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$this->compareExpectations($state);
}
public function testApplyALabelToNoArticles() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]));
$state = $this->primeExpectations($this->data, $this->checkMembers);
$this->compareExpectations($state);
}
public function testClearALabelFromNoArticles() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$this->compareExpectations($state);
}
public function testReplaceArticlesOfALabel() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]), Database::ASSOC_REPLACE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$state['arsse_label_members']['rows'][2][3] = 0;
$state['arsse_label_members']['rows'][4][3] = 1;
$state['arsse_label_members']['rows'][] = [1,2,1,1];
$this->compareExpectations($state);
}
public function testPurgeArticlesOfALabel() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REPLACE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$state['arsse_label_members']['rows'][2][3] = 0;
$this->compareExpectations($state);
}
public function testApplyALabelToArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");

45
tests/cases/Database/SeriesTag.php

@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Misc\Date;
use Phake;
@ -311,7 +312,7 @@ trait SeriesTag {
Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]);
}
public function testListTagledSubscriptions() {
public function testListTaggedSubscriptions() {
$exp = [1,5];
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1));
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true));
@ -323,17 +324,17 @@ trait SeriesTag {
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true));
}
public function testListTagledSubscriptionsForAMissingTag() {
public function testListTaggedSubscriptionsForAMissingTag() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3);
}
public function testListTagledSubscriptionsForAnInvalidTag() {
public function testListTaggedSubscriptionsForAnInvalidTag() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1);
}
public function testListTagledSubscriptionsWithoutAuthority() {
public function testListTaggedSubscriptionsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1);
@ -348,14 +349,14 @@ trait SeriesTag {
}
public function testClearATagFromSubscriptions() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true);
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_tag_members']['rows'][0][2] = 0;
$this->compareExpectations($state);
}
public function testApplyATagToSubscriptionsByName() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, true);
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], Database::ASSOC_ADD, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_tag_members']['rows'][1][2] = 1;
$state['arsse_tag_members']['rows'][] = [1,4,1];
@ -363,12 +364,42 @@ trait SeriesTag {
}
public function testClearATagFromSubscriptionsByName() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true);
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_tag_members']['rows'][0][2] = 0;
$this->compareExpectations($state);
}
public function testApplyATagToNoSubscriptionsByName() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$this->compareExpectations($state);
}
public function testClearATagFromNoSubscriptionsByName() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$this->compareExpectations($state);
}
public function testReplaceSubscriptionsOfATag() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4], Database::ASSOC_REPLACE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_tag_members']['rows'][0][2] = 0;
$state['arsse_tag_members']['rows'][1][2] = 1;
$state['arsse_tag_members']['rows'][2][2] = 0;
$state['arsse_tag_members']['rows'][] = [1,4,1];
$this->compareExpectations($state);
}
public function testPurgeSubscriptionsOfATag() {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [], Database::ASSOC_REPLACE);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_tag_members']['rows'][0][2] = 0;
$state['arsse_tag_members']['rows'][2][2] = 0;
$this->compareExpectations($state);
}
public function testApplyATagToSubscriptionsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");

16
tests/cases/REST/TinyTinyRSS/TestAPI.php

@ -1289,18 +1289,18 @@ LONG_STRING;
];
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true)->thenReturn(42);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true)->thenReturn(47);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE)->thenReturn(42);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE)->thenReturn(47);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD)->thenReturn(5);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD)->thenReturn(2);
$exp = $this->respGood(['status' => "OK", 'updated' => 89]);
$this->assertMessage($exp, $this->req($in[0]));
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE);
$exp = $this->respGood(['status' => "OK", 'updated' => 7]);
$this->assertMessage($exp, $this->req($in[1]));
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD);
$exp = $this->respGood(['status' => "OK", 'updated' => 0]);
$this->assertMessage($exp, $this->req($in[2]));
$exp = $this->respErr("INCORRECT_USAGE");

Loading…
Cancel
Save