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) {
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 {
$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) {
$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;
}
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;
}
@ -358,13 +358,14 @@ class Database {
join user on user is owner
join arsse_feeds on feed = arsse_feeds.id
left join topmost on folder=f_id",
"", // where terms
"pinned desc, title" // order by terms
"str", // where terms
$this->dateFormatDefault
);
$q->setOrder("pinned desc, title");
// 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
$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)) {
// 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
@ -373,11 +374,11 @@ class Database {
// if a folder is specified, make sure it exists
$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
$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
$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 {
@ -598,15 +599,10 @@ class Database {
// a simple WHERE clause is required here
$q->setWhere("arsse_feeds.id is ?", "int", $id);
} else {
$q->setCTE("user(user) as (SELECT ?)", "str", $user);
$q->setCTE(
"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
);
$q->setCTE("user(user)", "SELECT ?", "str", $user);
$q->setCTE("feeds(feed)", "SELECT feed from arsse_subscriptions join user on user is owner", [], [], "join feeds on arsse_articles.feed is feeds.feed");
}
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 {
@ -634,27 +630,27 @@ class Database {
join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id
left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id
",
"", // WHERE clause
"edition".($context->reverse ? " desc" : ""), // ORDER BY clause
$context->limit,
$context->offset
["str", "str", "str"],
[$this->dateFormatDefault, $this->dateFormatDefault, $this->dateFormatDefault]
);
$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 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)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]);
} 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);
$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
$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 {
// 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
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->starred()) $q->setWhere("starred is ?", "bool", $context->starred);
// 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 {
@ -724,9 +720,9 @@ class Database {
FROM arsse_articles"
);
// 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
$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 an edition is specified, filter for its previously identified 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
$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], "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()) {
// 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);
$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
$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 {
// 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 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))",
$q->setCTE("requested_articles(id,edition)",
"SELECT article,id as edition from arsse_editions where edition in ($inParams)",
$inTypes,
$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(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))",
$q->setCTE("requested_articles(id,edition)",
"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)");
$q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0");
}
// filter based on edition offset
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->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_date,to_insert,honour_read,honour_star)", // CTE table specification
[], // CTE types
[], // CTE values
$query // new query body
);
$out += $this->db->prepare($q)->run()->changes();
$q->pushCTE("target_articles(id,edition,modified_date,to_insert,honour_read,honour_star)");
$q->setBody($query);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
// commit the transaction
$tr->commit();

14
lib/Db/AbstractDriver.php

@ -7,11 +7,11 @@ abstract class AbstractDriver implements Driver {
protected $transDepth = 0;
protected $transStatus = [];
public abstract function prepareArray($query, array $paramTypes): Statement;
public abstract function prepareArray(string $query, array $paramTypes): Statement;
public function schemaVersion(): int {
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) {
return 0;
}
@ -111,26 +111,26 @@ abstract class AbstractDriver implements Driver {
if($this->isLocked()) return false;
$uuid = UUID::mintStr();
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) {
return false;
}
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 {
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;
}
public function isLocked(): bool {
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);
}
}

4
lib/Db/Driver.php

@ -33,6 +33,6 @@ interface Driver {
// perform a single unsanitized query and return a result set
function query(string $query): Result;
// ready a prepared statement for later execution
function prepare($query, ...$paramType): Statement;
function prepareArray($query, array $paramTypes): Statement;
function prepare(string $query, ...$paramType): 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]);
}
public function prepareArray($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 = [];
}
public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
try {
$s = $this->db->prepare($query);
} catch(\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild();
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 $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->st = $st;
$this->rebindArray($bindings);
$this->values['pre'] = $preValues;
$this->values['post'] = $postValues;
}
public function __destruct() {
@ -49,7 +47,6 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
$this->st->clear();
$values = [$this->values['pre'], $values, $this->values['post']];
$this->bindValues($values);
try {
$r = $this->st->execute();

90
lib/Misc/Query.php

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

96
sql/SQLite3/0.sql

@ -1,91 +1,90 @@
-- settings
create table arsse_settings(
key varchar(255) primary key not null, -- setting key
value varchar(255) -- setting value, serialized as a string
) without rowid;
-- metadata
create table arsse_meta(
key text primary key not null, -- metadata key
value text -- metadata value, serialized as a string
);
-- users
create table arsse_users(
id TEXT primary key not null, -- user id
password TEXT, -- password, salted and hashed; if using external authentication this would be blank
name TEXT, -- display name
avatar_url TEXT, -- external URL to avatar
avatar_type TEXT, -- internal avatar image's MIME content type
avatar_data BLOB, -- internal avatar image's binary data
id text primary key not null, -- user id
password text, -- password, salted and hashed; if using external authentication this would be blank
name text, -- display name
avatar_type text, -- internal avatar image's MIME content type
avatar_data blob, -- internal avatar image's binary data
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
create table arsse_feeds(
id integer primary key, -- sequence number
url TEXT not null, -- URL of feed
title TEXT, -- default title of feed
favicon TEXT, -- URL of favicon
source TEXT, -- URL of site to which the feed belongs
url text not null, -- URL of feed
title text, -- default title of feed
favicon text, -- URL of favicon
source text, -- URL of site to which the feed belongs
updated datetime, -- time at which the feed was last fetched
modified datetime, -- time at which the feed last actually changed
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_msg TEXT, -- last error message
username TEXT not null default '', -- HTTP authentication username
password TEXT not null default '', -- HTTP authentication password (this is stored in plain text)
err_msg text, -- last error message
username text not null default '', -- HTTP authentication username
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
);
-- users' subscriptions to newsfeeds, with settings
create table arsse_subscriptions(
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
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
title TEXT, -- user-supplied title
title text, -- user-supplied title
order_type int not null default 0, -- NextCloud sort order
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
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
create table arsse_articles(
id integer primary key, -- sequence number
feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
url TEXT, -- URL of article
title TEXT, -- article title
author TEXT, -- author's name
url text, -- URL of article
title text, -- article title
author text, -- author's name
published datetime, -- time of original publication
edited datetime, -- time of last edit
modified datetime not null default CURRENT_TIMESTAMP, -- date when article properties were last modified
content TEXT, -- content, as (X)HTML
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_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.
content text, -- content, as (X)HTML
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_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.
);
-- enclosures associated with articles
create table arsse_enclosures(
article integer not null references arsse_articles(id) on delete cascade,
url TEXT,
type varchar(255)
url text,
type text
);
-- users' actions on newsfeed entries
create table arsse_marks(
id integer primary key,
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,
starred boolean not null default 0,
modified datetime not null default CURRENT_TIMESTAMP,
@ -99,19 +98,12 @@ create table arsse_editions(
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
create table arsse_categories(
article integer not null references arsse_articles(id) on delete cascade,
name TEXT
name text
);
-- set version marker
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->assertTrue($this->drv->lock());
$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->isLocked());
$this->assertFalse($this->drv->lock());

4
tests/Db/SQLite3/TestDbUpdateSQLite3.php

@ -12,7 +12,7 @@ class TestDbUpdateSQLite3 extends \PHPUnit\Framework\TestCase {
protected $vfs;
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";
function setUp() {
@ -56,7 +56,7 @@ class TestDbUpdateSQLite3 extends \PHPUnit\Framework\TestCase {
}
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->drv->schemaUpdate(1);
}

Loading…
Cancel
Save