diff --git a/lib/Db/AbstractDriver.php b/lib/Db/AbstractDriver.php index 9d2867b..45b9476 100644 --- a/lib/Db/AbstractDriver.php +++ b/lib/Db/AbstractDriver.php @@ -13,13 +13,13 @@ abstract class AbstractDriver implements Driver { protected $transDepth = 0; protected $transStatus = []; + abstract protected function lock(): bool; + abstract protected function unlock(bool $rollback = false): bool; abstract protected function getError(): string; - /** @codeCoverageIgnore */ public function schemaVersion(): int { - // FIXME: generic schemaVersion() will need to be covered for database engines other than SQLite try { - return (int) $this->query("SELECT value from arsse_meta where key is schema_version")->getValue(); + return (int) $this->query("SELECT value from arsse_meta where key = 'schema_version'")->getValue(); } catch (Exception $e) { return 0; } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 99154ed..ef09aad 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -13,11 +13,10 @@ use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { - public function __construct(string $user = null, string $pass = null, string $db = null, string $host = null, int $port = null, string $schema = null, string $service = null) { // check to make sure required extension is loaded if (!static::requirementsMet()) { - throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore + throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore } $user = $user ?? Arsse::$conf->dbPostgreSQLUser; $pass = $pass ?? Arsse::$conf->dbPostgreSQLPass; @@ -27,16 +26,9 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $schema = $schema ?? Arsse::$conf->dbPostgreSQLSchema; $service = $service ?? Arsse::$conf->dbPostgreSQLService; $this->makeConnection($user, $pass, $db, $host, $port, $service); - } - - public static function requirementsMet(): bool { - // stub: native interface is not yet supported - return false; - } - - protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) { - // stub: native interface is not yet supported - throw new \Exception; + foreach (static::makeSetupQueries($schema) as $q) { + $this->exec($q); + } } public static function makeConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service): string { @@ -73,7 +65,15 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { return implode(" ", $out); } - public function __destruct() { + public static function makeSetupQueries(string $schema = ""): array { + $out = [ + "SET TIME ZONE UTC", + "SET DateStyle = 'ISO, MDY'" + ]; + if (strlen($schema) > 0) { + $out[] = 'SET search_path = \'"'.str_replace('"', '""', $schema).'", "$user", public\''; + } + return $out; } /** @codeCoverageIgnore */ @@ -96,48 +96,57 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { return "PostgreSQL"; } - public function schemaVersion(): int { - // stub - return 0; + public function charsetAcceptable(): bool { + return $this->query("SELECT pg_encoding_to_char(encoding) from pg_database where datname = current_database()")->getValue() == "UTF8"; } - public function schemaUpdate(int $to, string $basePath = null): bool { - // stub - return false; + protected function lock(): bool { + $this->exec("BEGIN TRANSACTION"); + if ($this->schemaVersion()) { + $this->exec("LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"); + } + return true; } - public function charsetAcceptable(): bool { - // stub + protected function unlock(bool $rollback = false): bool { + $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + public function __destruct() { + } + + public static function requirementsMet(): bool { + // stub: native interface is not yet supported + return false; + } + + protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) { + // stub: native interface is not yet supported + throw new \Exception; + } + + /** @codeCoverageIgnore */ protected function getError(): string { - // stub + // stub: native interface is not yet supported return ""; } + /** @codeCoverageIgnore */ public function exec(string $query): bool { - // stub + // stub: native interface is not yet supported return true; } + /** @codeCoverageIgnore */ public function query(string $query): \JKingWeb\Arsse\Db\Result { - // stub + // stub: native interface is not yet supported return new ResultEmpty; } + /** @codeCoverageIgnore */ public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { - // stub + // stub: native interface is not yet supported return new Statement($this->db, $s, $paramTypes); } - - protected function lock(): bool { - // stub - return true; - } - - protected function unlock(bool $rollback = false): bool { - // stub - return true; - } } diff --git a/sql/PostgreSQL/0.sql b/sql/PostgreSQL/0.sql new file mode 100644 index 0000000..461b1f2 --- /dev/null +++ b/sql/PostgreSQL/0.sql @@ -0,0 +1,123 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- metadata +create table arsse_meta( + key text primary key, + value text +); + +-- users +create table arsse_users( + id text primary key, + password text, + name text, + avatar_type text, + avatar_data bytea, + admin smallint default 0, + rights bigint not null default 0 +); + +-- extra user metadata +create table arsse_users_meta( + owner text not null references arsse_users(id) on delete cascade on update cascade, + key text not null, + value text, + primary key(owner,key) +); + +-- NextCloud News folders and TT-RSS categories +create table arsse_folders( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + parent bigint references arsse_folders(id) on delete cascade, + name text not null, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, -- + unique(owner,name,parent) +); + +-- newsfeeds, deduplicated +create table arsse_feeds( + id bigserial primary key, + url text not null, + title text, + favicon text, + source text, + updated timestamp(0) with time zone, + modified timestamp(0) with time zone, + next_fetch timestamp(0) with time zone, + orphaned timestamp(0) with time zone, + etag text not null default '', + err_count bigint not null default 0, + err_msg text, + username text not null default '', + password text not null default '', + size bigint not null default 0, + scrape smallint not null default 0, + unique(url,username,password) +); + +-- users' subscriptions to newsfeeds, with settings +create table arsse_subscriptions( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + feed bigint not null references arsse_feeds(id) on delete cascade, + added timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + title text, + order_type smallint not null default 0, + pinned smallint not null default 0, + folder bigint references arsse_folders(id) on delete cascade, + unique(owner,feed) +); + +-- entries in newsfeeds +create table arsse_articles( + id bigserial primary key, + feed bigint not null references arsse_feeds(id) on delete cascade, + url text, + title text, + author text, + published timestamp(0) with time zone, + edited timestamp(0) with time zone, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + content text, + guid text, + url_title_hash text not null, + url_content_hash text not null, + title_content_hash text not null +); + +-- enclosures associated with articles +create table arsse_enclosures( + article bigint not null references arsse_articles(id) on delete cascade, + url text, + type text +); + +-- users' actions on newsfeed entries +create table arsse_marks( + article bigint not null references arsse_articles(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade on update cascade, + read smallint not null default 0, + starred smallint not null default 0, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + primary key(article,subscription) +); + +-- IDs for specific editions of articles (required for at least NextCloud News) +create table arsse_editions( + id bigserial primary key, + article bigint not null references arsse_articles(id) on delete cascade, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP +); + +-- author categories associated with newsfeed entries +create table arsse_categories( + article bigint not null references arsse_articles(id) on delete cascade, + name text +); + +-- set version marker +insert into arsse_meta(key,value) values('schema_version','1'); diff --git a/sql/PostgreSQL/1.sql b/sql/PostgreSQL/1.sql new file mode 100644 index 0000000..f8a950b --- /dev/null +++ b/sql/PostgreSQL/1.sql @@ -0,0 +1,36 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Sessions for Tiny Tiny RSS (and possibly others) +create table arsse_sessions ( + id text primary key, + created timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + expires timestamp(0) with time zone not null, + user text not null references arsse_users(id) on delete cascade on update cascade +); + +-- User-defined article labels for Tiny Tiny RSS +create table arsse_labels ( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + name text not null, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + unique(owner,name) +); + +-- Labels assignments for articles +create table arsse_label_members ( + label bigint not null references arsse_labels(id) on delete cascade, + article bigint not null references arsse_articles(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned smallint not null default 1, + modified timestamp(0) with time zone not null default CURRENT_TIMESTAMP, + primary key(label,article) +); + +-- alter marks table to add Tiny Tiny RSS' notes +alter table arsse_marks add column note text not null default ''; + +-- set version marker +update arsse_meta set value = '2' where key = 'schema_version'; diff --git a/sql/PostgreSQL/2.sql b/sql/PostgreSQL/2.sql new file mode 100644 index 0000000..cf5cf3d --- /dev/null +++ b/sql/PostgreSQL/2.sql @@ -0,0 +1,22 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- create a case-insensitive generic collation sequence +create collation nocase( + provider = icu, + locale = '@kf=false' +); + +-- Correct collation sequences +alter table arsse_users alter column id type text collate nocase; +alter table arsse_folders alter column name type text collate nocase; +alter table arsse_feeds alter column title type text collate nocase; +alter table arsse_subscriptions alter column title type text collate nocase; +alter table arsse_articles alter column title type text collate nocase; +alter table arsse_articles alter column author type text collate nocase; +alter table arsse_categories alter column name type text collate nocase; +alter table arsse_labels alter column name type text collate nocase; + +-- set version marker +update arsse_meta set value = '3' where key = 'schema_version'; diff --git a/tests/cases/Db/TestStatement.php b/tests/cases/Db/TestStatement.php index 9b4143a..0b161ad 100644 --- a/tests/cases/Db/TestStatement.php +++ b/tests/cases/Db/TestStatement.php @@ -28,7 +28,11 @@ class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { $drvPgsql = (function() { if (\JKingWeb\Arsse\Db\PostgreSQL\PDODriver::requirementsMet()) { $connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(true, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, ""); - return new \PDO("pgsql:".$connString, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $c = new \PDO("pgsql:".$connString, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + foreach (\JKingWeb\Arsse\Db\PostgreSQL\PDODriver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) { + $c->exec($q); + } + return $c; } })(); $drvPdo = (function() { @@ -173,7 +177,6 @@ class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { $dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto")); $dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC")); $tests = [ - /* input, type, expected binding as SQL fragment */ 'Null as integer' => [null, "integer", "null"], 'Null as float' => [null, "float", "null"], 'Null as string' => [null, "string", "null"], @@ -321,7 +324,6 @@ class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { $dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto")); $dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC")); $tests = [ - /* input, type, expected binding as SQL fragment */ 'Null as binary' => [null, "binary", "null"], 'Null as strict binary' => [null, "strict binary", "x''"], 'True as binary' => [true, "binary", "x'31'"],