Browse Source

Cleanup

- Revamped design of Query class to be more consistent and predictable, and generally suck less
- Removed special case for Query class in Statement class
- Cleaned up database schema somewhat
microsub
J. King 7 years ago
parent
commit
7e7b204d85
  1. 84
      lib/Database.php
  2. 14
      lib/Db/AbstractDriver.php
  3. 4
      lib/Db/Driver.php
  4. 13
      lib/Db/SQLite3/Driver.php
  5. 5
      lib/Db/SQLite3/Statement.php
  6. 90
      lib/Misc/Query.php
  7. 96
      sql/SQLite3/0.sql
  8. 2
      tests/Db/SQLite3/TestDbDriverSQLite3.php
  9. 4
      tests/Db/SQLite3/TestDbUpdateSQLite3.php

84
lib/Database.php

@ -91,19 +91,19 @@ class Database {
} }
public function settingGet(string $key) { public function settingGet(string $key) {
return $this->db->prepare("SELECT value from arsse_settings where key is ?", "str")->run($key)->getValue(); return $this->db->prepare("SELECT value from arsse_meta where key is ?", "str")->run($key)->getValue();
} }
public function settingSet(string $key, string $value): bool { public function settingSet(string $key, string $value): bool {
$out = !$this->db->prepare("UPDATE arsse_settings set value = ? where key is ?", "str", "str")->run($value, $key)->changes(); $out = !$this->db->prepare("UPDATE arsse_meta set value = ? where key is ?", "str", "str")->run($value, $key)->changes();
if(!$out) { if(!$out) {
$out = $this->db->prepare("INSERT INTO arsse_settings(key,value)", "str", "str")->run($key, $value)->changes(); $out = $this->db->prepare("INSERT INTO arsse_meta(key,value)", "str", "str")->run($key, $value)->changes();
} }
return (bool) $out; return (bool) $out;
} }
public function settingRemove(string $key): bool { public function settingRemove(string $key): bool {
$this->db->prepare("DELETE from arsse_settings where key is ?", "str")->run($key); $this->db->prepare("DELETE from arsse_meta where key is ?", "str")->run($key);
return true; return true;
} }
@ -358,13 +358,14 @@ class Database {
join user on user is owner join user on user is owner
join arsse_feeds on feed = arsse_feeds.id join arsse_feeds on feed = arsse_feeds.id
left join topmost on folder=f_id", left join topmost on folder=f_id",
"", // where terms "str", // where terms
"pinned desc, title" // order by terms $this->dateFormatDefault
); );
$q->setOrder("pinned desc, title");
// define common table expressions // define common table expressions
$q->setCTE("user(user) as (SELECT ?)", "str", $user); // the subject user; this way we only have to pass it to prepare() once $q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once
// topmost folders belonging to the user // topmost folders belonging to the user
$q->setCTE("topmost(f_id,top) as (select id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id)"); $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id");
if(!is_null($id)) { if(!is_null($id)) {
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings // if an ID is specified, add a suitable WHERE condition and bindings
@ -373,11 +374,11 @@ class Database {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $folder); $this->folderValidateId($user, $folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree // 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", $folder); $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder);
// add a suitable WHERE condition // add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)"); $q->setWhere("folder in (select folder from folders)");
} }
return $this->db->prepare($q, "str")->run($this->dateFormatDefault); return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
} }
public function subscriptionRemove(string $user, int $id): bool { public function subscriptionRemove(string $user, int $id): bool {
@ -598,15 +599,10 @@ class Database {
// a simple WHERE clause is required here // a simple WHERE clause is required here
$q->setWhere("arsse_feeds.id is ?", "int", $id); $q->setWhere("arsse_feeds.id is ?", "int", $id);
} else { } else {
$q->setCTE("user(user) as (SELECT ?)", "str", $user); $q->setCTE("user(user)", "SELECT ?", "str", $user);
$q->setCTE( $q->setCTE("feeds(feed)", "SELECT feed from arsse_subscriptions join user on user is owner", [], [], "join feeds on arsse_articles.feed is feeds.feed");
"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($q)->run()->getValue(); return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
} }
public function articleList(string $user, Context $context = null): Db\Result { public function articleList(string $user, Context $context = null): Db\Result {
@ -634,27 +630,27 @@ class Database {
join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id
left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id
", ",
"", // WHERE clause ["str", "str", "str"],
"edition".($context->reverse ? " desc" : ""), // ORDER BY clause [$this->dateFormatDefault, $this->dateFormatDefault, $this->dateFormatDefault]
$context->limit,
$context->offset
); );
$q->setCTE("user(user) as (SELECT ?)", "str", $user); $q->setOrder("edition".($context->reverse ? " desc" : ""));
$q->setLimit($context->limit, $context->offset);
$q->setCTE("user(user)", "SELECT ?", "str", $user);
if($context->subscription()) { if($context->subscription()) {
// if a subscription is specified, make sure it exists // if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed']; $id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription // 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)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]);
} else if($context->folder()) { } else if($context->folder()) {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder); $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 // 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); $q->setCTE("folders(folder)", "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 // 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)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder");
} else { } else {
// otherwise add a CTE for all the user's subscriptions // 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)", "SELECT feed,id from arsse_subscriptions join user on user is owner");
} }
// filter based on edition offset // filter based on edition offset
if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition); if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition);
@ -666,7 +662,7 @@ class Database {
if($context->unread()) $q->setWhere("unread is ?", "bool", $context->unread); if($context->unread()) $q->setWhere("unread is ?", "bool", $context->unread);
if($context->starred()) $q->setWhere("starred is ?", "bool", $context->starred); if($context->starred()) $q->setWhere("starred is ?", "bool", $context->starred);
// perform the query and return results // perform the query and return results
return $this->db->prepare($q, "str", "str", "str")->run($this->dateFormatDefault, $this->dateFormatDefault, $this->dateFormatDefault); return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
} }
public function articleMark(string $user, array $data, Context $context = null): bool { public function articleMark(string $user, array $data, Context $context = null): bool {
@ -724,9 +720,9 @@ class Database {
FROM arsse_articles" FROM arsse_articles"
); );
// common table expression for the affected user // common table expression for the affected user
$q->setCTE("user(user) as (SELECT ?)", "str", $user); $q->setCTE("user(user)", "SELECT ?", "str", $user);
// common table expression with the values to set // common table expression with the values to set
$q->setCTE("target_values(read,starred) as (select ?,?)", ["bool","bool"], $values); $q->setCTE("target_values(read,starred)", "SELECT ?,?", ["bool","bool"], $values);
if($context->edition()) { if($context->edition()) {
// if an edition is specified, filter for its previously identified article // if an edition is specified, filter for its previously identified article
$q->setWhere("arsse_articles.id is ?", "int", $edition['article']); $q->setWhere("arsse_articles.id is ?", "int", $edition['article']);
@ -737,25 +733,25 @@ class Database {
// if a subscription is specified, make sure it exists // if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed']; $id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription // 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], "join subscribed_feeds on feed is subscribed_feeds.id"); $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id");
} else if($context->folder()) { } else if($context->folder()) {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder); $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 // 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); $q->setCTE("folders(folder)", "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 // 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)", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); $q->setCTE("subscribed_feeds(id,sub)", "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 { } else {
// otherwise add a CTE for all the user's subscriptions // 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"); $q->setCTE("subscribed_feeds(id,sub)", "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($context->editions()) {
// if multiple specific editions have been requested, prepare a CTE to list them and their articles // 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(!$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 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"); list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
$q->setCTE( $q->setCTE("requested_articles(id,edition)",
"requested_articles(id,edition) as (select article,id as edition from arsse_editions where edition in ($inParams))", "SELECT article,id as edition from arsse_editions where edition in ($inParams)",
$inTypes, $inTypes,
$context->editions $context->editions
); );
@ -765,15 +761,15 @@ class Database {
if(!$context->articles) throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element 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 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"); list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
$q->setCTE( $q->setCTE("requested_articles(id,edition)",
"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))", "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, $inTypes,
$context->articles $context->articles
); );
$q->setWhere("arsse_articles.id in (select id from requested_articles)"); $q->setWhere("arsse_articles.id in (select id from requested_articles)");
} else { } else {
// if neither list is specified, mock an empty table // if neither list is specified, mock an empty table
$q->setCTE("requested_articles(id,edition) as (select 'empty','table' where 1 is 0)"); $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0");
} }
// filter based on edition offset // filter based on edition offset
if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition); if($context->oldestEdition()) $q->setWhere("edition >= ?", "int", $context->oldestEdition);
@ -782,13 +778,9 @@ class Database {
if($context->modifiedSince()) $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); if($context->modifiedSince()) $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
if($context->notModifiedSince()) $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); 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 // push the current query onto the CTE stack and execute the query we're actually interested in
$q->pushCTE( $q->pushCTE("target_articles(id,edition,modified_date,to_insert,honour_read,honour_star)");
"target_articles(id,edition,modified_date,to_insert,honour_read,honour_star)", // CTE table specification $q->setBody($query);
[], // CTE types $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
[], // CTE values
$query // new query body
);
$out += $this->db->prepare($q)->run()->changes();
} }
// commit the transaction // commit the transaction
$tr->commit(); $tr->commit();

14
lib/Db/AbstractDriver.php

@ -7,11 +7,11 @@ abstract class AbstractDriver implements Driver {
protected $transDepth = 0; protected $transDepth = 0;
protected $transStatus = []; protected $transStatus = [];
public abstract function prepareArray($query, array $paramTypes): Statement; public abstract function prepareArray(string $query, array $paramTypes): Statement;
public function schemaVersion(): int { public function schemaVersion(): int {
try { try {
return (int) $this->query("SELECT value from arsse_settings where key is schema_version")->getValue(); return (int) $this->query("SELECT value from arsse_meta where key is schema_version")->getValue();
} catch(Exception $e) { } catch(Exception $e) {
return 0; return 0;
} }
@ -111,26 +111,26 @@ abstract class AbstractDriver implements Driver {
if($this->isLocked()) return false; if($this->isLocked()) return false;
$uuid = UUID::mintStr(); $uuid = UUID::mintStr();
try { try {
$this->prepare("INSERT INTO arsse_settings(key,value) values(?,?)", "str", "str")->run("lock", $uuid); $this->prepare("INSERT INTO arsse_meta(key,value) values(?,?)", "str", "str")->run("lock", $uuid);
} catch(ExceptionInput $e) { } catch(ExceptionInput $e) {
return false; return false;
} }
sleep(1); sleep(1);
return ($this->query("SELECT value from arsse_settings where key is 'lock'")->getValue() == $uuid); return ($this->query("SELECT value from arsse_meta where key is 'lock'")->getValue() == $uuid);
} }
public function unlock(): bool { public function unlock(): bool {
if($this->schemaVersion() < 1) return true; if($this->schemaVersion() < 1) return true;
$this->exec("DELETE from arsse_settings where key is 'lock'"); $this->exec("DELETE from arsse_meta where key is 'lock'");
return true; return true;
} }
public function isLocked(): bool { public function isLocked(): bool {
if($this->schemaVersion() < 1) return false; if($this->schemaVersion() < 1) return false;
return ($this->query("SELECT count(*) from arsse_settings where key is 'lock'")->getValue() > 0); return ($this->query("SELECT count(*) from arsse_meta where key is 'lock'")->getValue() > 0);
} }
public function prepare($query, ...$paramType): Statement { public function prepare(string $query, ...$paramType): Statement {
return $this->prepareArray($query, $paramType); return $this->prepareArray($query, $paramType);
} }
} }

4
lib/Db/Driver.php

@ -33,6 +33,6 @@ interface Driver {
// perform a single unsanitized query and return a result set // perform a single unsanitized query and return a result set
function query(string $query): Result; function query(string $query): Result;
// ready a prepared statement for later execution // ready a prepared statement for later execution
function prepare($query, ...$paramType): Statement; function prepare(string $query, ...$paramType): Statement;
function prepareArray($query, array $paramTypes): Statement; function prepareArray(string $query, array $paramTypes): Statement;
} }

13
lib/Db/SQLite3/Driver.php

@ -125,22 +125,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
return new Result($r, [$changes, $lastId]); return new Result($r, [$changes, $lastId]);
} }
public function prepareArray($query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
if($query instanceof \JKingWeb\Arsse\Misc\Query) {
$preValues = $query->getCTEValues();
$postValues = $query->getWhereValues();
$paramTypes = [$query->getCTETypes(), $paramTypes, $query->getWhereTypes()];
$query = $query->getQuery();
} else {
$preValues = [];
$postValues = [];
}
try { try {
$s = $this->db->prepare($query); $s = $this->db->prepare($query);
} catch(\Exception $e) { } catch(\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild(); list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData); throw new $excClass($excMsg, $excData);
} }
return new Statement($this->db, $s, $paramTypes, $preValues, $postValues); return new Statement($this->db, $s, $paramTypes);
} }
} }

5
lib/Db/SQLite3/Statement.php

@ -26,12 +26,10 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
protected $db; protected $db;
protected $st; protected $st;
public function __construct(\SQLite3 $db, \SQLite3Stmt $st, array $bindings = [], array $preValues = [], array $postValues = []) { public function __construct(\SQLite3 $db, \SQLite3Stmt $st, array $bindings = []) {
$this->db = $db; $this->db = $db;
$this->st = $st; $this->st = $st;
$this->rebindArray($bindings); $this->rebindArray($bindings);
$this->values['pre'] = $preValues;
$this->values['post'] = $postValues;
} }
public function __destruct() { public function __destruct() {
@ -49,7 +47,6 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
$this->st->clear(); $this->st->clear();
$values = [$this->values['pre'], $values, $this->values['post']];
$this->bindValues($values); $this->bindValues($values);
try { try {
$r = $this->st->execute(); $r = $this->st->execute();

90
lib/Misc/Query.php

@ -3,7 +3,9 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Misc; namespace JKingWeb\Arsse\Misc;
class Query { class Query {
protected $body = ""; protected $qBody = ""; // main query body
protected $tBody = []; // main query parameter types
protected $vBody = []; // main query parameter values
protected $qCTE = []; // Common table expression query components protected $qCTE = []; // Common table expression query components
protected $tCTE = []; // Common table expression type bindings protected $tCTE = []; // Common table expression type bindings
protected $vCTE = []; // Common table expression binding values protected $vCTE = []; // Common table expression binding values
@ -16,27 +18,30 @@ class Query {
protected $offset = 0; protected $offset = 0;
function __construct(string $body, string $where = "", string $order = "", int $limit = 0, int $offset = 0) { function __construct(string $body = "", $types = null, $values = null) {
if(strlen($body)) $this->body = $body; $this->setBody($body, $types, $values);
if(strlen($where)) $this->qWhere[] = $where;
if(strlen($order)) $this->order[] = $order;
$this->limit = $limit;
$this->offset = $offset;
} }
function setCTE(string $body, $types = null, $values = null, string $join = ''): bool { function setBody(string $body = "", $types = null, $values = null): bool {
if(!strlen($body)) return false; $this->qBody = $body;
$this->qCTE[] = $body; if(!is_null($types)) {
$this->tBody[] = $types;
$this->vBody[] = $values;
}
return true;
}
function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool {
$this->qCTE[] = "$tableSpec as ($body)";
if(!is_null($types)) { if(!is_null($types)) {
$this->tCTE[] = $types; $this->tCTE[] = $types;
$this->vCTE[] = $values; $this->vCTE[] = $values;
} }
if(strlen($join)) $this->jCTE[] = $join; // the CTE may only participate in subqueries rather than a join on the main query if(strlen($join)) $this->jCTE[] = $join; // the CTE might only participate in subqueries rather than a join on the main query
return true; return true;
} }
function setWhere(string $where, $types = null, $values = null): bool { function setWhere(string $where, $types = null, $values = null): bool {
if(!strlen($where)) return false;
$this->qWhere[] = $where; $this->qWhere[] = $where;
if(!is_null($types)) { if(!is_null($types)) {
$this->tWhere[] = $types; $this->tWhere[] = $types;
@ -45,8 +50,7 @@ class Query {
return true; return true;
} }
function setOrder(string $oder, bool $prepend = false): bool { function setOrder(string $order, bool $prepend = false): bool {
if(!strlen($order)) return false;
if($prepend) { if($prepend) {
array_unshift($this->order, $order); array_unshift($this->order, $order);
} else { } else {
@ -55,7 +59,29 @@ class Query {
return true; return true;
} }
function getQuery(): string { function setLimit(int $limit, int $offset = 0): bool {
$this->limit = $limit;
$this->offset = $offset;
return true;
}
function pushCTE(string $tableSpec, string $join = ''): 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, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query
$this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]);
$this->jCTE = [];
$this->tBody = [];
$this->vBody = [];
$this->qWhere = [];
$this->tWhere = [];
$this->vWhere = [];
$this->order = [];
$this->setLimit(0,0);
if(strlen($join)) $this->jCTE[] = $join;
return true;
}
function __toString(): string {
$out = ""; $out = "";
if(sizeof($this->qCTE)) { if(sizeof($this->qCTE)) {
// start with common table expressions // start with common table expressions
@ -66,28 +92,16 @@ class Query {
return $out; return $out;
} }
function pushCTE(string $tableSpec, $types, $values, string $body, string $where = "", string $order = "", int $limit = 0, int $offset = 0): bool { function getQuery(): string {
// this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack return $this->__toString();
// 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()); function getTypes(): array {
array_push($values, $this->getWhereValues()); return [$this->tCTE, $this->tBody, $this->tWhere];
if($this->limit) { }
array_push($types, "strict int");
array_push($values, $this->limit); function getValues(): array {
} return [$this->vCTE, $this->vBody, $this->vWhere];
if($this->offset) {
array_push($types, "strict int");
array_push($values, $this->offset);
}
$this->setCTE($tableSpec." as (".$this->buildQueryBody().")", $types, $values);
$this->jCTE = [];
$this->qWhere = [];
$this->tWhere = [];
$this->vWhere = [];
$this->order = [];
$this->__construct($body, $where, $order, $limit, $offset);
return true;
} }
function getWhereTypes(): array { function getWhereTypes(): array {
@ -109,7 +123,7 @@ class Query {
protected function buildQueryBody(): string { protected function buildQueryBody(): string {
$out = ""; $out = "";
// add the body // add the body
$out .= $this->body; $out .= $this->qBody;
if(sizeof($this->qCTE)) { if(sizeof($this->qCTE)) {
// add any joins against CTEs // add any joins against CTEs
$out .= " ".implode(" ", $this->jCTE); $out .= " ".implode(" ", $this->jCTE);

96
sql/SQLite3/0.sql

@ -1,91 +1,90 @@
-- settings -- metadata
create table arsse_settings( create table arsse_meta(
key varchar(255) primary key not null, -- setting key key text primary key not null, -- metadata key
value varchar(255) -- setting value, serialized as a string value text -- metadata value, serialized as a string
) without rowid; );
-- users -- users
create table arsse_users( create table arsse_users(
id TEXT primary key not null, -- user id id text primary key not null, -- user id
password TEXT, -- password, salted and hashed; if using external authentication this would be blank password text, -- password, salted and hashed; if using external authentication this would be blank
name TEXT, -- display name name text, -- display name
avatar_url TEXT, -- external URL to avatar avatar_type text, -- internal avatar image's MIME content type
avatar_type TEXT, -- internal avatar image's MIME content type avatar_data blob, -- internal avatar image's binary data
avatar_data BLOB, -- internal avatar image's binary data
rights integer not null default 0 -- any administrative rights the user may have rights integer not null default 0 -- any administrative rights the user may have
) without rowid; );
-- NextCloud folders
create table arsse_folders(
id integer primary key, -- sequence number
owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of folder
parent integer references arsse_folders(id) on delete cascade, -- parent folder id
name text not null, -- folder name
modified datetime not null default CURRENT_TIMESTAMP, --
unique(owner,name,parent) -- cannot have multiple folders with the same name under the same parent for the same owner
);
-- newsfeeds, deduplicated -- newsfeeds, deduplicated
create table arsse_feeds( create table arsse_feeds(
id integer primary key, -- sequence number id integer primary key, -- sequence number
url TEXT not null, -- URL of feed url text not null, -- URL of feed
title TEXT, -- default title of feed title text, -- default title of feed
favicon TEXT, -- URL of favicon favicon text, -- URL of favicon
source TEXT, -- URL of site to which the feed belongs source text, -- URL of site to which the feed belongs
updated datetime, -- time at which the feed was last fetched updated datetime, -- time at which the feed was last fetched
modified datetime, -- time at which the feed last actually changed modified datetime, -- time at which the feed last actually changed
next_fetch datetime, -- time at which the feed should next be fetched next_fetch datetime, -- time at which the feed should next be fetched
etag TEXT not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes etag text not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes
err_count integer not null default 0, -- count of successive times update resulted in error since last successful update err_count integer not null default 0, -- count of successive times update resulted in error since last successful update
err_msg TEXT, -- last error message err_msg text, -- last error message
username TEXT not null default '', -- HTTP authentication username username text not null default '', -- HTTP authentication username
password TEXT not null default '', -- HTTP authentication password (this is stored in plain text) password text not null default '', -- HTTP authentication password (this is stored in plain text)
unique(url,username,password) -- a URL with particular credentials should only appear once unique(url,username,password) -- a URL with particular credentials should only appear once
); );
-- users' subscriptions to newsfeeds, with settings -- users' subscriptions to newsfeeds, with settings
create table arsse_subscriptions( create table arsse_subscriptions(
id integer primary key, -- sequence number id integer primary key, -- sequence number
owner TEXT not null references arsse_users(id) on delete cascade on update cascade, -- owner of subscription owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of subscription
feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
added datetime not null default CURRENT_TIMESTAMP, -- time at which feed was added added datetime not null default CURRENT_TIMESTAMP, -- time at which feed was added
modified datetime not null default CURRENT_TIMESTAMP, -- date at which subscription properties were last modified modified datetime not null default CURRENT_TIMESTAMP, -- date at which subscription properties were last modified
title TEXT, -- user-supplied title title text, -- user-supplied title
order_type int not null default 0, -- NextCloud sort order order_type int not null default 0, -- NextCloud sort order
pinned boolean not null default 0, -- whether feed is pinned (always sorts at top) pinned boolean not null default 0, -- whether feed is pinned (always sorts at top)
folder integer references arsse_folders(id) on delete cascade, -- TT-RSS category (nestable); the first-level category (which acts as NextCloud folder) is joined in when needed folder integer references arsse_folders(id) on delete cascade, -- TT-RSS category (nestable); the first-level category (which acts as NextCloud folder) is joined in when needed
unique(owner,feed) -- a given feed should only appear once for a given owner unique(owner,feed) -- a given feed should only appear once for a given owner
); );
-- TT-RSS categories and NextCloud folders
create table arsse_folders(
id integer primary key, -- sequence number
owner TEXT not null references arsse_users(id) on delete cascade on update cascade, -- owner of folder
parent integer references arsse_folders(id) on delete cascade, -- parent folder id
name TEXT not null, -- folder name
modified datetime not null default CURRENT_TIMESTAMP, --
unique(owner,name,parent) -- cannot have multiple folders with the same name under the same parent for the same owner
);
-- entries in newsfeeds -- entries in newsfeeds
create table arsse_articles( create table arsse_articles(
id integer primary key, -- sequence number id integer primary key, -- sequence number
feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
url TEXT, -- URL of article url text, -- URL of article
title TEXT, -- article title title text, -- article title
author TEXT, -- author's name author text, -- author's name
published datetime, -- time of original publication published datetime, -- time of original publication
edited datetime, -- time of last edit edited datetime, -- time of last edit
modified datetime not null default CURRENT_TIMESTAMP, -- date when article properties were last modified modified datetime not null default CURRENT_TIMESTAMP, -- date when article properties were last modified
content TEXT, -- content, as (X)HTML content text, -- content, as (X)HTML
guid TEXT, -- GUID guid text, -- GUID
url_title_hash TEXT not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid. url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid.
url_content_hash TEXT not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid. url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
title_content_hash TEXT not null -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid. title_content_hash text not null -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
); );
-- enclosures associated with articles -- enclosures associated with articles
create table arsse_enclosures( create table arsse_enclosures(
article integer not null references arsse_articles(id) on delete cascade, article integer not null references arsse_articles(id) on delete cascade,
url TEXT, url text,
type varchar(255) type text
); );
-- users' actions on newsfeed entries -- users' actions on newsfeed entries
create table arsse_marks( create table arsse_marks(
id integer primary key, id integer primary key,
article integer not null references arsse_articles(id) on delete cascade, article integer not null references arsse_articles(id) on delete cascade,
owner TEXT not null references arsse_users(id) on delete cascade on update cascade, owner text not null references arsse_users(id) on delete cascade on update cascade,
read boolean not null default 0, read boolean not null default 0,
starred boolean not null default 0, starred boolean not null default 0,
modified datetime not null default CURRENT_TIMESTAMP, modified datetime not null default CURRENT_TIMESTAMP,
@ -99,19 +98,12 @@ create table arsse_editions(
modified datetime not null default CURRENT_TIMESTAMP modified datetime not null default CURRENT_TIMESTAMP
); );
-- user labels associated with newsfeed entries
create table arsse_labels(
sub_article integer not null references arsse_subscription_articles(id) on delete cascade,
name TEXT
);
create index arsse_label_names on arsse_labels(name);
-- author categories associated with newsfeed entries -- author categories associated with newsfeed entries
create table arsse_categories( create table arsse_categories(
article integer not null references arsse_articles(id) on delete cascade, article integer not null references arsse_articles(id) on delete cascade,
name TEXT name text
); );
-- set version marker -- set version marker
pragma user_version = 1; pragma user_version = 1;
insert into arsse_settings(key,value) values('schema_version','1'); insert into arsse_meta(key,value) values('schema_version','1');

2
tests/Db/SQLite3/TestDbDriverSQLite3.php

@ -303,7 +303,7 @@ class TestDbDriverSQLite3 extends \PHPUnit\Framework\TestCase {
$this->assertFalse($this->drv->isLocked()); $this->assertFalse($this->drv->isLocked());
$this->assertTrue($this->drv->lock()); $this->assertTrue($this->drv->lock());
$this->assertFalse($this->drv->isLocked()); $this->assertFalse($this->drv->isLocked());
$this->drv->exec("CREATE TABLE arsse_settings(key primary key, value, type) without rowid; PRAGMA user_version=1"); $this->drv->exec("CREATE TABLE arsse_meta(key text primary key, value text); PRAGMA user_version=1");
$this->assertTrue($this->drv->lock()); $this->assertTrue($this->drv->lock());
$this->assertTrue($this->drv->isLocked()); $this->assertTrue($this->drv->isLocked());
$this->assertFalse($this->drv->lock()); $this->assertFalse($this->drv->lock());

4
tests/Db/SQLite3/TestDbUpdateSQLite3.php

@ -12,7 +12,7 @@ class TestDbUpdateSQLite3 extends \PHPUnit\Framework\TestCase {
protected $vfs; protected $vfs;
protected $base; protected $base;
const MINIMAL1 = "create table arsse_settings(key text primary key not null, value text, type text not null); pragma user_version=1"; const MINIMAL1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1";
const MINIMAL2 = "pragma user_version=2"; const MINIMAL2 = "pragma user_version=2";
function setUp() { function setUp() {
@ -56,7 +56,7 @@ class TestDbUpdateSQLite3 extends \PHPUnit\Framework\TestCase {
} }
function testLoadIncompleteFile() { function testLoadIncompleteFile() {
file_put_contents($this->base."0.sql", "create table arsse_settings(key text primary key not null, value text, type text not null);"); file_put_contents($this->base."0.sql", "create table arsse_meta(key text primary key not null, value text);");
$this->assertException("updateFileIncomplete", "Db"); $this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1); $this->drv->schemaUpdate(1);
} }

Loading…
Cancel
Save