1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 21:22:40 +00:00

Merge branch 'pg'

This commit is contained in:
J. King 2018-12-14 09:28:19 -05:00
commit 8d9d249b88
128 changed files with 4471 additions and 3208 deletions

View file

@ -17,6 +17,7 @@ $paths = [
$rules = [
'@PSR2' => true,
'braces' => ['position_after_functions_and_oop_constructs' => "same"],
'function_declaration' => ['closure_function_spacing' => "none"],
];
$finder = \PhpCsFixer\Finder::create();

View file

@ -1,3 +1,15 @@
Version 0.6.0 (????-??-??)
==========================
New features:
- Support for PostgreSQL databases
Bug fixes:
- Use a general-purpose Unicode collation with SQLite databases
Changes:
- Improve performance of common database queries by 80-90%
Version 0.5.1 (2018-11-10)
==========================

View file

@ -4,7 +4,7 @@ The Arsse is a news aggregator server which implements multiple synchronization
At present the software should be considered in an "alpha" state: though its core subsystems are covered by unit tests and should be free of major bugs, not everything has been rigorously tested. Additionally, many features one would expect from other similar software have yet to be implemented. Areas of future work include:
- Support for more database engines (PostgreSQL, MySQL, MariaDB)
- Support for more database engines (MySQL, MariaDB)
- Providing more sync protocols (Google Reader, Fever, others)
- Better packaging and configuration samples
@ -16,7 +16,9 @@ The Arsse has the following requirements:
- PHP 7.0.7 or later with the following extensions:
- [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [pcre](http://php.net/manual/en/book.pcre.php)
- [dom](http://php.net/manual/en/book.dom.php), [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed)
- [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://ca1.php.net/manual/en/ref.pdo-sqlite.php)
- Either of:
- [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://ca1.php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases
- [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://ca1.php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 9.1 or later databases
- Privileges to create and run daemon processes on the server
## Installation
@ -69,6 +71,10 @@ The Arsse is made available under the permissive MIT license. See the `LICENSE`
Please refer to `CONTRIBUTING.md` for guidelines on contributing code to The Arsse.
## Database compatibility notes
Functionally there is no reason to prefer either SQLite or PostgreSQL over the other. SQLite, however, is significantly simpler to set up in most cases, requiring only read and write access to a containing directory in order to function. On the other hand PostgreSQL may perform better than SQLite when serving hundreds of users or more, but this has not been tested.
## Protocol compatibility notes
### General

View file

@ -50,6 +50,21 @@ class RoboFile extends \Robo\Tasks {
* recommended if debugging facilities are not otherwise needed.
*/
public function coverage(array $args): Result {
// run tests with code coverage reporting enabled
$exec = $this->findCoverageEngine();
return $this->runTests($exec, "coverage", array_merge(["--coverage-html", self::BASE_TEST."coverage"], $args));
}
/** Produces a code coverage report, with redundant tests
*
* Depending on the environment, some tests that normally provide
* coverage may be skipped, while working alternatives are normally
* suppressed for reasons of time. This coverage report will try to
* run all tests which may cover code.
*
* See also help for the "coverage" task for more details.
*/
public function coverageFull(array $args): Result {
// run tests with code coverage reporting enabled
$exec = $this->findCoverageEngine();
return $this->runTests($exec, "typical", array_merge(["--coverage-html", self::BASE_TEST."coverage"], $args));
@ -91,6 +106,9 @@ class RoboFile extends \Robo\Tasks {
case "quick":
$set = ["--exclude-group", "optional,slow"];
break;
case "coverage":
$set = ["--exclude-group", "optional,coverageOptional"];
break;
case "full":
$set = [];
break;

View file

@ -9,6 +9,12 @@ When upgrading between any two versions of The Arsse, the following are usually
- If installing from source, update dependencies with `composer install -o --no-dev`
Upgrading from 0.5.1 to 0.6.0
=============================
- The database schema has changed from rev3 to rev4; if upgrading the database manually, apply the 3.sql file
Upgrading from 0.2.1 to 0.3.0
=============================

View file

@ -42,7 +42,8 @@
},
"autoload-dev": {
"psr-4": {
"JKingWeb\\Arsse\\Test\\": "tests/lib/"
"JKingWeb\\Arsse\\Test\\": "tests/lib/",
"JKingWeb\\Arsse\\TestCase\\": "tests/cases/"
}
}
}

View file

@ -26,6 +26,7 @@ abstract class AbstractException extends \Exception {
"Db/Exception.fileUnwritable" => 10205,
"Db/Exception.fileUncreatable" => 10206,
"Db/Exception.fileCorrupt" => 10207,
"Db/Exception.connectionFailure" => 10208,
"Db/Exception.updateTooNew" => 10211,
"Db/Exception.updateManual" => 10212,
"Db/Exception.updateManualOnly" => 10213,

View file

@ -19,12 +19,30 @@ class Conf {
public $dbDriver = Db\SQLite3\Driver::class;
/** @var boolean Whether to attempt to automatically update the database when updated to a new version with schema changes */
public $dbAutoUpdate = true;
/** @var float Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */
public $dbTimeoutConnect = 5.0;
/** @var float Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */
public $dbTimeoutExec = 0.0;
/** @var string|null Full path and file name of SQLite database (if using SQLite) */
public $dbSQLite3File = null;
/** @var string Encryption key to use for SQLite database (if using a version of SQLite with SEE) */
public $dbSQLite3Key = "";
/** @var integer Number of seconds for SQLite to wait before returning a timeout error when writing to the database */
public $dbSQLite3Timeout = 60;
/** @var float Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
public $dbSQLite3Timeout = 60.0;
/** @var string Host name, address, or socket path of PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLHost = "";
/** @var string Log-in user name for PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLUser = "arsse";
/** @var string Log-in password for PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLPass = "";
/** @var integer Listening port for PostgreSQL database server (if using PostgreSQL over TCP) */
public $dbPostgreSQLPort = 5432;
/** @var string Database name on PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLDb = "arsse";
/** @var string Schema name in PostgreSQL database (if using PostgreSQL) */
public $dbPostgreSQLSchema = "";
/** @var string Service file entry to use (if using PostgreSQL); if using a service entry all above parameters except schema are ignored */
public $dbPostgreSQLService = "";
/** @var string Class of the user management driver in use (Internal by default) */
public $userDriver = User\Internal\Driver::class;

View file

@ -7,19 +7,15 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\DrUUID\UUID;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
class Database {
const SCHEMA_VERSION = 3;
const SCHEMA_VERSION = 4;
const LIMIT_ARTICLES = 50;
// articleList verbosity levels
const LIST_MINIMAL = 0; // only that metadata which is required for context matching
const LIST_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text
const LIST_TYPICAL = 2; // conservative, with the addition of content
const LIST_FULL = 3; // all possible fields
/** @var Db\Driver */
public $db;
@ -84,13 +80,26 @@ class Database {
protected function generateIn(array $values, string $type): array {
$out = [
[], // query clause
"", // query clause
[], // binding types
];
if (sizeof($values)) {
// the query clause is just a series of question marks separated by commas
$out[0] = implode(",", array_fill(0, sizeof($values), "?"));
// the binding types are just a repetition of the supplied type
$out[1] = array_fill(0, sizeof($values), $type);
} else {
// if the set is empty, some databases require a query which returns an empty set
$standin = [
'string' => "''",
'binary' => "''",
'datetime' => "''",
'integer' => "1",
'boolean' => "1",
'float' => "1.0",
][Statement::TYPES[$type] ?? "string"];
$out[0] = "select $standin where 1 = 0";
}
return $out;
}
@ -182,7 +191,7 @@ class Database {
$id = UUID::mint()->hex;
$expires = Date::add(Arsse::$conf->userSessionTimeout);
// save the session to the database
$this->db->prepare("INSERT INTO arsse_sessions(id,expires,user) values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user);
$this->db->prepare("INSERT INTO arsse_sessions(id,expires,\"user\") values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user);
// return the ID
return $id;
}
@ -193,12 +202,12 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// delete the session and report success.
return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and user = ?", "str", "str")->run($id, $user)->changes();
return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes();
}
public function sessionResume(string $id): array {
$maxAge = Date::sub(Arsse::$conf->userSessionLifetime);
$out = $this->db->prepare("SELECT id,created,expires,user from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow();
$out = $this->db->prepare("SELECT id,created,expires,\"user\" from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow();
// if the session does not exist or is expired, throw an exception
if (!$out) {
throw new User\ExceptionSession("invalid", $id);
@ -371,13 +380,13 @@ class Database {
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
$p = $this->db->prepare(
"WITH RECURSIVE
target as (select ? as user, ? as source, ? as dest, ? as rename),
folders as (SELECT id from arsse_folders join target on owner = user and coalesce(parent,0) = source union select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id)
target as (select ? as userid, ? as source, ? as dest, ? as rename),
folders as (SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source union select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id)
".
"SELECT
((select dest from target) is null or exists(select id from arsse_folders join target on owner = user and coalesce(id,0) = coalesce(dest,0))) as extant,
not exists(select id from folders where id = coalesce((select dest from target),0)) as valid,
not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select rename from target),(select name from arsse_folders join target on id = source))) as available
case when ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0))) then 1 else 0 end as extant,
case when not exists(select id from folders where id = coalesce((select dest from target),0)) then 1 else 0 end as valid,
case when not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select rename from target),(select name from arsse_folders join target on id = source))) then 1 else 0 end as available
",
"str",
"strict int",
@ -409,7 +418,7 @@ class Database {
// make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
$parent = $parent ? $parent : null;
if ($this->db->prepare("SELECT exists(select id from arsse_folders where coalesce(parent,0) = ? and name = ?)", "strict int", "str")->run($parent, $name)->getValue()) {
if ($this->db->prepare("SELECT count(*) from arsse_folders where coalesce(parent,0) = ? and name = ?", "strict int", "str")->run($parent, $name)->getValue()) {
throw new Db\ExceptionInput("constraintViolation", ["action" => $this->caller(), "field" => "name"]);
}
return true;
@ -462,15 +471,16 @@ class Database {
coalesce(arsse_subscriptions.title, arsse_feeds.title) as title,
(SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and read = 1) as unread
from arsse_subscriptions
join user on user = owner
join userdata on userid = owner
join arsse_feeds on feed = arsse_feeds.id
left join topmost on folder=f_id"
);
$q->setOrder("pinned desc, title collate nocase");
$nocase = $this->db->sqlToken("nocase");
$q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase");
// define common table expressions
$q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once
$q->setCTE("userdata(userid)", "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)", "SELECT id,id from arsse_folders join user on owner = 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 userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id");
if ($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
@ -795,73 +805,102 @@ class Database {
)->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC);
}
protected function articleQuery(string $user, Context $context, array $extraColumns = []): Query {
$extraColumns = implode(",", $extraColumns);
if (strlen($extraColumns)) {
$extraColumns .= ",";
protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query {
$greatest = $this->db->sqlToken("greatest");
// prepare the output column list
$colDefs = [
'id' => "arsse_articles.id",
'edition' => "latest_editions.edition",
'url' => "arsse_articles.url",
'title' => "arsse_articles.title",
'author' => "arsse_articles.author",
'content' => "arsse_articles.content",
'guid' => "arsse_articles.guid",
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
'subscription' => "arsse_subscriptions.id",
'feed' => "arsse_subscriptions.feed",
'starred' => "coalesce(arsse_marks.starred,0)",
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
'note' => "coalesce(arsse_marks.note,'')",
'published_date' => "arsse_articles.published",
'edited_date' => "arsse_articles.edited",
'modified_date' => "arsse_articles.modified",
'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(arsse_label_members.modified, '0001-01-01 00:00:00'))",
'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
'media_url' => "arsse_enclosures.url",
'media_type' => "arsse_enclosures.type",
];
if (!$cols) {
// if no columns are specified return a count
$columns = "count(distinct arsse_articles.id) as count";
} else {
$columns = [];
foreach ($cols as $col) {
$col = trim(strtolower($col));
if (!isset($colDefs[$col])) {
continue;
}
$columns[] = $colDefs[$col]." as ".$col;
}
$columns = implode(",", $columns);
}
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
$extraColumns
arsse_articles.id as id,
arsse_articles.feed as feed,
arsse_articles.modified as modified_date,
max(
arsse_articles.modified,
coalesce((select modified from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),''),
coalesce((select modified from arsse_label_members where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),'')
) as marked_date,
NOT (select count(*) from arsse_marks where article = arsse_articles.id and read = 1 and subscription in (select sub from subscribed_feeds)) as unread,
(select count(*) from arsse_marks where article = arsse_articles.id and starred = 1 and subscription in (select sub from subscribed_feeds)) as starred,
(select max(id) from arsse_editions where article = arsse_articles.id) as edition,
subscribed_feeds.sub as subscription
FROM arsse_articles"
$columns
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1
left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id",
["str"],
[$user]
);
$q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article");
if ($cols) {
// if there are no output columns requested we're getting a count and should not group, but otherwise we should
$q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition");
}
$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)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed = subscribed_feeds.id");
$this->subscriptionValidateId($user, $context->subscription);
// filter for the subscription
$q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription);
} elseif ($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)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder);
// add another CTE for the subscriptions within the folder
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner join folders on arsse_subscriptions.folder = folders.folder", [], [], "join subscribed_feeds on feed = subscribed_feeds.id");
// limit subscriptions to the listed folders
$q->setWhere("arsse_subscriptions.folder in (select folder from folders)");
} elseif ($context->folderShallow()) {
// if a shallow folder is specified, make sure it exists
$this->folderValidateId($user, $context->folderShallow);
// if it does exist, add a CTE with only its subscriptions (and not those of its descendents)
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner and coalesce(folder,0) = ?", "strict int", $context->folderShallow, "join subscribed_feeds on feed = subscribed_feeds.id");
} else {
// otherwise add a CTE for all the user's subscriptions
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner", [], [], "join subscribed_feeds on feed = subscribed_feeds.id");
// if it does exist, filter for that folder only
$q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow);
}
if ($context->edition()) {
// if an edition is specified, filter for its previously identified article
$q->setWhere("arsse_articles.id = (select article from arsse_editions where id = ?)", "int", $context->edition);
// if an edition is specified, first validate it, then filter for it
$this->articleValidateEdition($user, $context->edition);
$q->setWhere("latest_editions.edition = ?", "int", $context->edition);
} elseif ($context->article()) {
// if an article is specified, filter for it (it has already been validated above)
// if an article is specified, first validate it, then filter for it
$this->articleValidateId($user, $context->article);
$q->setWhere("arsse_articles.id = ?", "int", $context->article);
}
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, filter against the list
if (!$context->editions) {
throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) {
throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
}
list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
$q->setCTE(
"requested_articles(id,edition)",
"SELECT article,id as edition from arsse_editions where edition in ($inParams)",
$inTypes,
$context->editions
);
$q->setWhere("arsse_articles.id in (select id from requested_articles)");
$q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions);
} elseif ($context->articles()) {
// if multiple specific articles have been requested, prepare a CTE to list them and their articles
if (!$context->articles) {
@ -870,21 +909,13 @@ class Database {
throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
}
list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
$q->setCTE(
"requested_articles(id,edition)",
"SELECT id,(select max(id) from arsse_editions where article = 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)", "SELECT 'empty','table' where 1 = 0");
$q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles);
}
// filter based on label by ID or name
if ($context->labelled()) {
// any label (true) or no label (false)
$q->setWhere((!$context->labelled ? "not " : "")."exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and subscription in (select sub from subscribed_feeds))");
$isOrIsNot = (!$context->labelled ? "is" : "is not");
$q->setWhere("arsse_labels.id $isOrIsNot null");
} elseif ($context->label() || $context->labelName()) {
// specific label ID or name
if ($context->label()) {
@ -892,7 +923,7 @@ class Database {
} else {
$id = $this->labelValidateId($user, $context->labelName, true)['id'];
}
$q->setWhere("exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and label = ?)", "int", $id);
$q->setWhere("arsse_labels.id = ?", "int", $id);
}
// filter based on article or edition offset
if ($context->oldestArticle()) {
@ -902,40 +933,41 @@ class Database {
$q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle);
}
if ($context->oldestEdition()) {
$q->setWhere("edition >= ?", "int", $context->oldestEdition);
$q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition);
}
if ($context->latestEdition()) {
$q->setWhere("edition <= ?", "int", $context->latestEdition);
$q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition);
}
// filter based on time at which an article was changed by feed updates (modified), or by user action (marked)
if ($context->modifiedSince()) {
$q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
$q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince);
}
if ($context->notModifiedSince()) {
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
$q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince);
}
if ($context->markedSince()) {
$q->setWhere("marked_date >= ?", "datetime", $context->markedSince);
$q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince);
}
if ($context->notMarkedSince()) {
$q->setWhere("marked_date <= ?", "datetime", $context->notMarkedSince);
$q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince);
}
// filter for un/read and un/starred status if specified
if ($context->unread()) {
$q->setWhere("unread = ?", "bool", $context->unread);
$q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread);
}
if ($context->starred()) {
$q->setWhere("starred = ?", "bool", $context->starred);
$q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred);
}
// filter based on whether the article has a note
if ($context->annotated()) {
$q->setWhere((!$context->annotated ? "not " : "")."exists(select modified from arsse_marks where article = arsse_articles.id and note <> '' and subscription in (select sub from subscribed_feeds))");
$comp = ($context->annotated) ? "<>" : "=";
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
}
// return the query
return $q;
}
protected function articleChunk(Context $context): array {
protected function contextChunk(Context $context): array {
$exception = "";
if ($context->editions()) {
// editions take precedence over articles
@ -959,13 +991,13 @@ class Database {
}
}
public function articleList(string $user, Context $context = null, int $fields = self::LIST_FULL): Db\Result {
public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
if ($contexts = $this->contextChunk($context)) {
$out = [];
$tr = $this->begin();
foreach ($contexts as $context) {
@ -974,46 +1006,9 @@ class Database {
$tr->commit();
return new Db\ResultAggregate(...$out);
} else {
$columns = [];
switch ($fields) {
// NOTE: the cases all cascade into each other: a given verbosity level is always a superset of the previous one
case self::LIST_FULL: // everything
$columns = array_merge($columns, [
"(select note from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note",
]);
// no break
case self::LIST_TYPICAL: // conservative, plus content
$columns = array_merge($columns, [
"content",
"arsse_enclosures.url as media_url", // enclosures are potentially large due to data: URLs
"arsse_enclosures.type as media_type", // FIXME: enclosures should eventually have their own fetch method
]);
// no break
case self::LIST_CONSERVATIVE: // base metadata, plus anything that is not likely to be large text
$columns = array_merge($columns, [
"arsse_articles.url as url",
"arsse_articles.title as title",
"(select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id where arsse_feeds.id = arsse_articles.feed) as subscription_title",
"author",
"guid",
"published as published_date",
"edited as edited_date",
"url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint",
]);
// no break
case self::LIST_MINIMAL: // base metadata (always included: required for context matching)
$columns = array_merge($columns, [
// id, subscription, feed, modified_date, marked_date, unread, starred, edition
"edited as edited_date",
]);
break;
default:
throw new Exception("constantUnknown", $fields);
}
$q = $this->articleQuery($user, $context, $columns);
$q->setOrder("edited_date".($context->reverse ? " desc" : ""));
$q->setOrder("edition".($context->reverse ? " desc" : ""));
$q->setJoin("left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id");
$q = $this->articleQuery($user, $context, $fields);
$q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : ""));
$q->setOrder("latest_editions.edition".($context->reverse ? " desc" : ""));
// perform the query and return results
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
@ -1025,7 +1020,7 @@ class Database {
}
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
if ($contexts = $this->contextChunk($context)) {
$out = 0;
$tr = $this->begin();
foreach ($contexts as $context) {
@ -1034,9 +1029,7 @@ class Database {
$tr->commit();
return $out;
} else {
$q = $this->articleQuery($user, $context);
$q->pushCTE("selected_articles");
$q->setBody("SELECT count(*) from selected_articles");
$q = $this->articleQuery($user, $context, []);
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
}
@ -1045,9 +1038,17 @@ class Database {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
'note' => $data['note'] ?? null,
];
if (!isset($data['read']) && !isset($data['starred']) && !isset($data['note'])) {
return 0;
}
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
if ($contexts = $this->contextChunk($context)) {
$out = 0;
$tr = $this->begin();
foreach ($contexts as $context) {
@ -1056,63 +1057,69 @@ class Database {
$tr->commit();
return $out;
} else {
// sanitize input
$values = [
isset($data['read']) ? $data['read'] : null,
isset($data['starred']) ? $data['starred'] : null,
isset($data['note']) ? $data['note'] : null,
];
// the two queries we want to execute to make the requested changes
$queries = [
"UPDATE arsse_marks
set
read = case when (select honour_read from target_articles where target_articles.id = article) = 1 then (select read from target_values) else read end,
starred = coalesce((select starred from target_values),starred),
note = coalesce((select note from target_values),note),
modified = CURRENT_TIMESTAMP
WHERE
subscription in (select sub from subscribed_feeds)
and article in (select id from target_articles where to_insert = 0 and (honour_read = 1 or honour_star = 1 or (select note from target_values) is not null))",
"INSERT INTO arsse_marks(subscription,article,read,starred,note)
select
(select id from arsse_subscriptions join user on user = owner where arsse_subscriptions.feed = target_articles.feed),
id,
coalesce((select read from target_values) * honour_read,0),
coalesce((select starred from target_values),0),
coalesce((select note from target_values),'')
from target_articles where to_insert = 1 and (honour_read = 1 or honour_star = 1 or coalesce((select note from target_values),'') <> '')"
];
$out = 0;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this->begin();
// if an edition context is specified, make sure it's valid
$out = 0;
if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) {
// first prepare a query to insert any missing marks rows for the articles we want to mark
// but only insert new mark records if we're setting at least one "positive" mark
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article
$q->pushCTE("missing_marks(article,subscription)");
$q->setBody("INSERT INTO arsse_marks(article,subscription) SELECT article,subscription from missing_marks");
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) {
// if marking by edition both read and something else, do separate marks for starred and note than for read
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
// set read marks
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
$q->pushCTE("target_articles(article,subscription)");
$q->setBody("UPDATE arsse_marks set read = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
// get the articles associated with the requested editions
if ($context->edition()) {
// make sure the edition exists
$edition = $this->articleValidateEdition($user, $context->edition);
// if the edition is not the latest, do not mark the read flag
if (!$edition['current']) {
$values[0] = null;
$context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null);
} else {
$context->articles($this->editionArticle(...$context->editions))->editions(null);
}
} elseif ($context->article()) {
// otherwise if an article context is specified, make sure it's valid
$this->articleValidateId($user, $context->article);
// set starred and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
});
list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// execute each query in sequence
foreach ($queries as $query) {
// first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles
$q = $this->articleQuery($user, $context, [
"(not exists(select article from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert",
"((select read from target_values) is not null and (select read from target_values) <> (coalesce((select read from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article = arsse_articles.id) in (select edition from requested_articles))) as honour_read",
"((select starred from target_values) is not null and (select starred from target_values) <> (coalesce((select starred from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star",
]);
// common table expression with the values to set
$q->setCTE("target_values(read,starred,note)", "SELECT ?,?,?", ["bool","bool","str"], $values);
// push the current query onto the CTE stack and execute the query we're actually interested in
$q->pushCTE("target_articles");
$q->setBody($query);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
// finally set the modification date for all touched marks and return the number of affected marks
$out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes();
} else {
if (!isset($data['read']) && ($context->edition() || $context->editions())) {
// get the articles associated with the requested editions
if ($context->edition()) {
$context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null);
} else {
$context->articles($this->editionArticle(...$context->editions))->editions(null);
}
if (!$context->article && !$context->articles) {
return 0;
}
}
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
});
list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
// commit the transaction
$tr->commit();
return $out;
}
@ -1125,11 +1132,11 @@ class Database {
return $this->db->prepare(
"SELECT
count(*) as total,
coalesce(sum(not read),0) as unread,
coalesce(sum(abs(read - 1)),0) as unread,
coalesce(sum(read),0) as read
FROM (
select read from arsse_marks where starred = 1 and subscription in (select id from arsse_subscriptions where owner = ?)
)",
) as starred_data",
"str"
)->run($user)->getRow();
}
@ -1140,12 +1147,10 @@ class Database {
}
$id = $this->articleValidateId($user, $id)['article'];
$out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll();
if (!$out) {
// flatten the result to return just the label ID or name, sorted
$out = $out ? array_column($out, !$byName ? "id" : "name") : [];
sort($out);
return $out;
} else {
// flatten the result to return just the label ID or name
return array_column($out, !$byName ? "id" : "name");
}
}
public function articleCategoriesGet(string $user, $id): array {
@ -1168,11 +1173,15 @@ class Database {
"SELECT
id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs
from arsse_feeds where id = ?".
"), latest_editions(article,edition) as (".
"SELECT article,max(id) from arsse_editions group by article".
"), excepted_articles(id,edition) as (".
"SELECT
arsse_articles.id, (select max(id) from arsse_editions where article = arsse_articles.id) as edition
arsse_articles.id as id,
latest_editions.edition as edition
from arsse_articles
join target_feed on arsse_articles.feed = target_feed.id
join latest_editions on arsse_articles.id = latest_editions.article
order by edition desc limit ?".
") ".
"DELETE from arsse_articles where
@ -1240,14 +1249,14 @@ class Database {
join arsse_feeds on arsse_feeds.id = arsse_articles.feed
join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id
WHERE
edition = ? and arsse_subscriptions.owner = ?",
arsse_editions.id = ? and arsse_subscriptions.owner = ?",
"int",
"str"
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]);
}
return $out;
return array_map("intval", $out);
}
public function editionLatest(string $user, Context $context = null): int {
@ -1255,19 +1264,35 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$context = $context ?? new Context;
$q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id left join arsse_feeds on arsse_articles.feed = arsse_feeds.id");
$q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ?", "str", $user);
if ($context->subscription()) {
// if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
$this->subscriptionValidateId($user, $context->subscription);
// a simple WHERE clause is required here
$q->setWhere("arsse_feeds.id = ?", "int", $id);
} else {
$q->setCTE("user(user)", "SELECT ?", "str", $user);
$q->setCTE("feeds(feed)", "SELECT feed from arsse_subscriptions join user on user = owner", [], [], "join feeds on arsse_articles.feed = feeds.feed");
$q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription);
}
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
public function editionArticle(int ...$edition): array {
$out = [];
$context = (new Context)->editions($edition);
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->contextChunk($context)) {
$articles = $editions = [];
foreach ($contexts as $context) {
$out = $this->editionArticle(...$context->editions);
$editions = array_merge($editions, array_map("intval", array_keys($out)));
$articles = array_merge($articles, array_map("intval", array_values($out)));
}
return array_combine($editions, $articles);
} else {
list($in, $inTypes) = $this->generateIn($context->editions, "int");
$out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($context->editions)->getAll();
return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : [];
}
}
public function labelAdd(string $user, array $data): int {
// if the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
@ -1286,14 +1311,16 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare(
"SELECT
"SELECT * FROM (
SELECT
id,name,
(select count(*) from arsse_label_members where label = id and assigned = 1) as articles,
(select count(*) from arsse_label_members
join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription
where label = id and assigned = 1 and read = 1
) as read
FROM arsse_labels where owner = ? and articles >= ? order by name
FROM arsse_labels where owner = ?) as label_data
where articles >= ? order by name
",
"str",
"int"
@ -1373,7 +1400,7 @@ class Database {
$this->labelValidateId($user, $id, $byName, false);
$field = !$byName ? "id" : "name";
$type = !$byName ? "int" : "str";
$out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ?", $type, "str")->run($id, $user)->getAll();
$out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ? order by article", $type, "str")->run($id, $user)->getAll();
if (!$out) {
// if no results were returned, do a full validation on the label ID
$this->labelValidateId($user, $id, $byName, true, true);
@ -1400,14 +1427,14 @@ class Database {
$q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id);
$q->pushCTE("target_articles");
$q->setBody(
"UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned = not ? and article in (select id from target_articles)",
"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]
);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
// next, if we're not removing, add any new entries that need to be added
if (!$remove) {
$q = $this->articleQuery($user, $context);
$q = $this->articleQuery($user, $context, ["id", "feed"]);
$q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id);
$q->pushCTE("target_articles");
$q->setBody(
@ -1415,10 +1442,10 @@ class Database {
arsse_label_members(label,article,subscription)
SELECT
?,id,
(select id from arsse_subscriptions join user on user = owner where arsse_subscriptions.feed = target_articles.feed)
(select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed)
FROM target_articles",
"int",
$id
["int", "str"],
[$id, $user]
);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}

View file

@ -13,18 +13,10 @@ 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();
} catch (Exception $e) {
return 0;
}
}
public function schemaUpdate(int $to, string $basePath = null): bool {
$ver = $this->schemaVersion();
if (!Arsse::$conf->dbAutoUpdate) {
@ -78,50 +70,63 @@ abstract class AbstractDriver implements Driver {
}
public function savepointCreate(bool $lock = false): int {
// if no transaction is active and a lock was requested, lock the database using a backend-specific routine
if ($lock && !$this->transDepth) {
$this->lock();
$this->locked = true;
}
// create a savepoint, incrementing the transaction depth
$this->exec("SAVEPOINT arsse_".(++$this->transDepth));
// set the state of the newly created savepoint to pending
$this->transStatus[$this->transDepth] = self::TR_PEND;
// return the depth number
return $this->transDepth;
}
public function savepointRelease(int $index = null): bool {
// assume the most recent savepoint if none was specified
$index = $index ?? $this->transDepth;
if (array_key_exists($index, $this->transStatus)) {
switch ($this->transStatus[$index]) {
case self::TR_PEND:
// release the requested savepoint and set its state to committed
$this->exec("RELEASE SAVEPOINT arsse_".$index);
$this->transStatus[$index] = self::TR_COMMIT;
// for any later pending savepoints, set their state to implicitly committed
$a = $index;
while (++$a && $a <= $this->transDepth) {
if ($this->transStatus[$a] <= self::TR_PEND) {
$this->transStatus[$a] = self::TR_PEND_COMMIT;
}
}
// return success
$out = true;
break;
case self::TR_PEND_COMMIT:
// set the state to explicitly committed
$this->transStatus[$index] = self::TR_COMMIT;
$out = true;
break;
case self::TR_PEND_ROLLBACK:
// set the state to explicitly committed
$this->transStatus[$index] = self::TR_COMMIT;
$out = false;
break;
case self::TR_COMMIT:
case self::TR_ROLLBACK: //@codeCoverageIgnore
// savepoint has already been released or rolled back; this is an error
throw new Exception("savepointStale", ['action' => "commit", 'index' => $index]);
default:
throw new Exception("savepointStatusUnknown", $this->transStatus[$index]); // @codeCoverageIgnore
}
if ($index==$this->transDepth) {
// if we've released the topmost savepoint, clean up all prior savepoints which have already been explicitly committed (or rolled back), if any
while ($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) {
array_pop($this->transStatus);
$this->transDepth--;
}
}
// if no savepoints are pending and the database was locked, unlock it
if (!$this->transDepth && $this->locked) {
$this->unlock();
$this->locked = false;

View file

@ -74,20 +74,26 @@ abstract class AbstractStatement implements Statement {
}
}
protected function bindValues(array $values, int $offset = 0): int {
$a = $offset;
protected function bindValues(array $values, int $offset = null): int {
$a = (int) $offset;
foreach ($values as $value) {
if (is_array($value)) {
// recursively flatten any arrays, which may be provided for SET or IN() clauses
$a += $this->bindValues($value, $a);
} elseif (array_key_exists($a, $this->types)) {
$value = $this->cast($value, $this->types[$a], $this->isNullable[$a]);
$this->bindValue($value, $this->types[$a], $a+1);
$a++;
$this->bindValue($value, $this->types[$a], ++$a);
} else {
throw new Exception("paramTypeMissing", $a+1);
}
}
// once the last value is bound, check that all parameters have been supplied values and bind null for any missing ones
// SQLite will happily substitute null for a missing value, but other engines (viz. PostgreSQL) produce an error
if (is_null($offset)) {
while ($a < sizeof($this->types)) {
$this->bindValue(null, $this->types[$a], ++$a);
}
}
return $a - $offset;
}
}

View file

@ -39,4 +39,6 @@ interface Driver {
public function prepareArray(string $query, array $paramTypes): Statement;
// report whether the database character set is correct/acceptable
public function charsetAcceptable(): bool;
// return an implementation-dependent form of a reference SQL function or operator
public function sqlToken(string $token): string;
}

View file

@ -26,13 +26,7 @@ trait PDODriver {
list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData);
}
$changes = $r->rowCount();
try {
$lastId = 0;
$lastId = $this->db->lastInsertId();
} catch (\PDOException $e) { // @codeCoverageIgnore
}
return new PDOResult($r, [$changes, $lastId]);
return new PDOResult($this->db, $r);
}
public function prepareArray(string $query, array $paramTypes): Statement {

View file

@ -7,15 +7,23 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
trait PDOError {
public function exceptionBuild(): array {
if ($this instanceof Statement) {
public function exceptionBuild(bool $statementError = null): array {
if ($statementError ?? ($this instanceof Statement)) {
$err = $this->st->errorInfo();
} else {
$err = $this->db->errorInfo();
}
switch ($err[0]) {
case "22P02":
case "42804":
return [ExceptionInput::class, 'engineTypeViolation', $err[2]];
case "23000":
case "23502":
case "23505":
return [ExceptionInput::class, "constraintViolation", $err[2]];
case "55P03":
case "57014":
return [ExceptionTimeout::class, 'general', $err[2]];
case "HY000":
// engine-specific errors
switch ($this->db->getAttribute(\PDO::ATTR_DRIVER_NAME)) {

View file

@ -10,26 +10,28 @@ use JKingWeb\Arsse\Db\Exception;
class PDOResult extends AbstractResult {
protected $set;
protected $db;
protected $cur = null;
protected $rows = 0;
protected $id = 0;
// actual public methods
public function changes(): int {
return $this->rows;
return $this->set->rowCount();
}
public function lastId(): int {
return $this->id;
try {
return (int) $this->db->lastInsertId();
} catch (\PDOException $e) {
return 0;
}
}
// constructor/destructor
public function __construct(\PDOStatement $result, array $changes = [0,0]) {
public function __construct(\PDO $db, \PDOStatement $result) {
$this->set = $result;
$this->rows = (int) $changes[0];
$this->id = (int) $changes[1];
$this->db = $db;
}
public function __destruct() {
@ -38,6 +40,7 @@ class PDOResult extends AbstractResult {
} catch (\PDOException $e) { // @codeCoverageIgnore
}
unset($this->set);
unset($this->db);
}
// PHP iterator methods

View file

@ -15,7 +15,7 @@ class PDOStatement extends AbstractStatement {
"datetime" => \PDO::PARAM_STR,
"binary" => \PDO::PARAM_LOB,
"string" => \PDO::PARAM_STR,
"boolean" => \PDO::PARAM_BOOL,
"boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
];
protected $st;
@ -28,10 +28,10 @@ class PDOStatement extends AbstractStatement {
}
public function __destruct() {
unset($this->st);
unset($this->st, $this->db);
}
public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
public function runArray(array $values = []): Result {
$this->st->closeCursor();
$this->bindValues($values);
try {
@ -40,13 +40,7 @@ class PDOStatement extends AbstractStatement {
list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData);
}
$changes = $this->st->rowCount();
try {
$lastId = 0;
$lastId = $this->db->lastInsertId();
} catch (\PDOException $e) { // @codeCoverageIgnore
}
return new PDOResult($this->st, [$changes, $lastId]);
return new PDOResult($this->db, $this->st);
}
protected function bindValue($value, string $type, int $position): bool {

View file

@ -0,0 +1,42 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout;
trait Dispatch {
protected function dispatchQuery(string $query, array $params = []) {
pg_send_query_params($this->db, $query, $params);
$result = pg_get_result($this->db);
if (($code = pg_result_error_field($result, \PGSQL_DIAG_SQLSTATE)) && isset($code) && $code) {
return $this->buildException($code, pg_result_error($result));
} else {
return $result;
}
}
protected function buildException(string $code, string $msg): array {
switch ($code) {
case "22P02":
case "42804":
return [ExceptionInput::class, 'engineTypeViolation', $msg];
case "23000":
case "23502":
case "23505":
return [ExceptionInput::class, "engineConstraintViolation", $msg];
case "55P03":
case "57014":
return [ExceptionTimeout::class, 'general', $msg];
default:
return [Exception::class, "engineErrorGeneral", $code.": ".$msg]; // @codeCoverageIgnore
}
}
}

View file

@ -0,0 +1,223 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout;
class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
use Dispatch;
protected $db;
protected $transStart = 0;
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", static::driverName()); // @codeCoverageIgnore
}
$user = $user ?? Arsse::$conf->dbPostgreSQLUser;
$pass = $pass ?? Arsse::$conf->dbPostgreSQLPass;
$db = $db ?? Arsse::$conf->dbPostgreSQLDb;
$host = $host ?? Arsse::$conf->dbPostgreSQLHost;
$port = $port ?? Arsse::$conf->dbPostgreSQLPort;
$schema = $schema ?? Arsse::$conf->dbPostgreSQLSchema;
$service = $service ?? Arsse::$conf->dbPostgreSQLService;
$this->makeConnection($user, $pass, $db, $host, $port, $service);
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 {
$base = [
'client_encoding' => "UTF8",
'application_name' => "arsse",
'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0),
];
$out = [];
if ($service != "") {
$out['service'] = $service;
} else {
if ($host != "") {
$out['host'] = $host;
}
if ($port != 5432 && !($host != "" && $host[0] == "/")) {
$out['port'] = (string) $port;
}
if ($db != "") {
$out['dbname'] = $db;
}
if (!$pdo) {
$out['user'] = $user;
if ($pass != "") {
$out['password'] = $pass;
}
}
}
ksort($out);
ksort($base);
$out = array_merge($out, $base);
$out = array_map(function($v, $k) {
return "$k='".str_replace("'", "\\'", str_replace("\\", "\\\\", $v))."'";
}, $out, array_keys($out));
return implode(" ", $out);
}
public static function makeSetupQueries(string $schema = ""): array {
$timeout = ceil(Arsse::$conf->dbTimeoutExec * 1000);
$out = [
"SET TIME ZONE UTC",
"SET DateStyle = 'ISO, MDY'",
"SET statement_timeout = '$timeout'",
];
if (strlen($schema) > 0) {
$schema = '"'.str_replace('"', '""', $schema).'"';
$out[] = "SET search_path = $schema, public";
}
return $out;
}
/** @codeCoverageIgnore */
public static function create(): \JKingWeb\Arsse\Db\Driver {
if (self::requirementsMet()) {
return new self;
} elseif (PDODriver::requirementsMet()) {
return new PDODriver;
} else {
throw new Exception("extMissing", self::driverName());
}
}
public static function schemaID(): string {
return "PostgreSQL";
}
public function charsetAcceptable(): bool {
return $this->query("SELECT pg_encoding_to_char(encoding) from pg_database where datname = current_database()")->getValue() == "UTF8";
}
public function schemaVersion(): int {
if ($this->query("SELECT count(*) from information_schema.tables where table_name = 'arsse_meta' and table_schema = current_schema()")->getValue()) {
return (int) $this->query("SELECT value from arsse_meta where key = 'schema_version'")->getValue();
} else {
return 0;
}
}
public function sqlToken(string $token): string {
switch (strtolower($token)) {
case "nocase":
return '"und-x-icu"';
default:
return $token;
}
}
public function savepointCreate(bool $lock = false): int {
if (!$this->transStart) {
$this->exec("BEGIN TRANSACTION");
$this->transStart = parent::savepointCreate($lock);
return $this->transStart;
} else {
return parent::savepointCreate($lock);
}
}
public function savepointRelease(int $index = null): bool {
$index = $index ?? $this->transDepth;
$out = parent::savepointRelease($index);
if ($index == $this->transStart) {
$this->exec("COMMIT");
$this->transStart = 0;
}
return $out;
}
public function savepointUndo(int $index = null): bool {
$index = $index ?? $this->transDepth;
$out = parent::savepointUndo($index);
if ($index == $this->transStart) {
$this->exec("ROLLBACK");
$this->transStart = 0;
}
return $out;
}
protected function lock(): bool {
if ($this->query("SELECT count(*) from information_schema.tables where table_schema = current_schema() and table_name = 'arsse_meta'")->getValue()) {
$this->exec("LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT");
}
return true;
}
protected function unlock(bool $rollback = false): bool {
// do nothing; transaction is committed or rolled back later
return true;
}
public function __destruct() {
if (isset($this->db)) {
pg_close($this->db);
unset($this->db);
}
}
public static function driverName(): string {
return Arsse::$lang->msg("Driver.Db.PostgreSQL.Name");
}
public static function requirementsMet(): bool {
return \extension_loaded("pgsql");
}
protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) {
$dsn = $this->makeconnectionString(false, $user, $pass, $db, $host, $port, $service);
set_error_handler(function(int $code, string $msg) {
$msg = substr($msg, 62);
throw new Exception("connectionFailure", ["PostgreSQL", $msg]);
});
try {
$this->db = pg_connect($dsn, \PGSQL_CONNECT_FORCE_NEW);
} finally {
restore_error_handler();
}
}
protected function getError(): string {
// stub
return "";
}
public function exec(string $query): bool {
pg_send_query($this->db, $query);
while ($result = pg_get_result($this->db)) {
if (($code = pg_result_error_field($result, \PGSQL_DIAG_SQLSTATE)) && isset($code) && $code) {
list($excClass, $excMsg, $excData) = $this->buildException($code, pg_result_error($result));
throw new $excClass($excMsg, $excData);
}
}
return true;
}
public function query(string $query): \JKingWeb\Arsse\Db\Result {
$r = $this->dispatchQuery($query);
if (is_resource($r)) {
return new Result($this->db, $r);
} else {
list($excClass, $excMsg, $excData) = $r;
throw new $excClass($excMsg, $excData);
}
}
public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
return new Statement($this->db, $query, $paramTypes);
}
}

View file

@ -0,0 +1,66 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout;
class PDODriver extends Driver {
use \JKingWeb\Arsse\Db\PDODriver;
protected $db;
public static function requirementsMet(): bool {
return class_exists("PDO") && in_array("pgsql", \PDO::getAvailableDrivers());
}
protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) {
$dsn = $this->makeconnectionString(true, $user, $pass, $db, $host, $port, $service);
try {
$this->db = new \PDO("pgsql:$dsn", $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_PERSISTENT => true,
]);
} catch (\PDOException $e) {
if ($e->getCode() == 7) {
switch (substr($e->getMessage(), 9, 5)) {
case "08006":
throw new Exception("connectionFailure", ["PostgreSQL", substr($e->getMessage(), 28)]);
default:
throw $e; // @codeCoverageIgnore
}
}
throw $e; // @codeCoverageIgnore
}
}
public function __destruct() {
unset($this->db);
}
/** @codeCoverageIgnore */
public static function create(): \JKingWeb\Arsse\Db\Driver {
if (self::requirementsMet()) {
return new self;
} elseif (Driver::requirementsMet()) {
return new Driver;
} else {
throw new Exception("extMissing", self::driverName());
}
}
public static function driverName(): string {
return Arsse::$lang->msg("Driver.Db.PostgreSQLPDO.Name");
}
public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
return new PDOStatement($this->db, $query, $paramTypes);
}
}

View file

@ -0,0 +1,56 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
class PDOStatement extends Statement {
use \JKingWeb\Arsse\Db\PDOError;
protected $db;
protected $st;
protected $qOriginal;
protected $qMunged;
protected $bindings;
public function __construct(\PDO $db, string $query, array $bindings = []) {
$this->db = $db;
$this->qOriginal = $query;
$this->retypeArray($bindings);
}
public function __destruct() {
unset($this->db, $this->st);
}
public function retypeArray(array $bindings, bool $append = false): bool {
if ($append) {
return parent::retypeArray($bindings, $append);
} else {
$this->bindings = $bindings;
parent::retypeArray($bindings, $append);
$this->qMunged = self::mungeQuery($this->qOriginal, $this->types, false);
try {
// statement creation with PostgreSQL should never fail (it is not evaluated at creation time)
$s = $this->db->prepare($this->qMunged);
} catch (\PDOException $e) { // @codeCoverageIgnore
list($excClass, $excMsg, $excData) = $this->exceptionBuild(true); // @codeCoverageIgnore
throw new $excClass($excMsg, $excData); // @codeCoverageIgnore
}
$this->st = new \JKingWeb\Arsse\Db\PDOStatement($this->db, $s, $this->bindings);
}
return true;
}
public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
return $this->st->runArray($values);
}
/** @codeCoverageIgnore */
protected function bindValue($value, string $type, int $position): bool {
// stub required by abstract parent, but never used
return true;
}
}

View file

@ -0,0 +1,48 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
use JKingWeb\Arsse\Db\Exception;
class Result extends \JKingWeb\Arsse\Db\AbstractResult {
protected $db;
protected $r;
protected $cur;
// actual public methods
public function changes(): int {
return pg_affected_rows($this->r);
}
public function lastId(): int {
if ($r = @pg_query($this->db, "SELECT lastval()")) {
return (int) pg_fetch_result($r, 0, 0);
} else {
return 0;
}
}
// constructor/destructor
public function __construct($db, $result) {
$this->db = $db;
$this->r = $result;
}
public function __destruct() {
pg_free_result($this->r);
unset($this->r, $this->db);
}
// PHP iterator methods
public function valid() {
$this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC);
return ($this->cur !== false);
}
}

View file

@ -0,0 +1,77 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout;
class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
use Dispatch;
const BINDINGS = [
"integer" => "bigint",
"float" => "decimal",
"datetime" => "timestamp(0) without time zone",
"binary" => "bytea",
"string" => "text",
"boolean" => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
];
protected $db;
protected $in = [];
protected $qOriginal;
protected $qMunged;
protected $bindings;
public function __construct($db, string $query, array $bindings = []) {
$this->db = $db;
$this->qOriginal = $query;
$this->retypeArray($bindings);
}
public function retypeArray(array $bindings, bool $append = false): bool {
if ($append) {
return parent::retypeArray($bindings, $append);
} else {
$this->bindings = $bindings;
parent::retypeArray($bindings, $append);
$this->qMunged = self::mungeQuery($this->qOriginal, $this->types, true);
}
return true;
}
public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
$this->in = [];
$this->bindValues($values);
$r = $this->dispatchQuery($this->qMunged, $this->in);
if (is_resource($r)) {
return new Result($this->db, $r);
} else {
list($excClass, $excMsg, $excData) = $r;
throw new $excClass($excMsg, $excData);
}
}
protected function bindValue($value, string $type, int $position): bool {
$this->in[] = $value;
return true;
}
protected static function mungeQuery(string $q, array $types, bool $mungeParamMarkers = true): string {
$q = explode("?", $q);
$out = "";
for ($b = 1; $b < sizeof($q); $b++) {
$a = $b - 1;
$mark = $mungeParamMarkers ? "\$$b" : "?";
$type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : "";
$out .= $q[$a].$mark.$type;
}
$out .= array_pop($q);
return $out;
}
}

View file

@ -16,7 +16,7 @@ class ResultAggregate extends AbstractResult {
// actual public methods
public function changes(): int {
return array_reduce($this->data, function ($sum, $value) {
return array_reduce($this->data, function($sum, $value) {
return $sum + $value->changes();
}, 0);
}

View file

@ -31,10 +31,6 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$timeout = Arsse::$conf->dbSQLite3Timeout * 1000;
try {
$this->makeConnection($dbFile, $dbKey);
// set the timeout; parameters are not allowed for pragmas, but this usage should be safe
$this->exec("PRAGMA busy_timeout = $timeout");
// set other initial options
$this->exec("PRAGMA foreign_keys = yes");
} catch (\Throwable $e) {
// if opening the database doesn't work, check various pre-conditions to find out what the problem might be
$files = [
@ -56,6 +52,15 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
// otherwise the database is probably corrupt
throw new Exception("fileCorrupt", $dbFile);
}
// set the timeout
$timeout = (int) ceil((Arsse::$conf->dbSQLite3Timeout ?? 0) * 1000);
$this->setTimeout($timeout);
// set other initial options
$this->exec("PRAGMA foreign_keys = yes");
// use a case-insensitive Unicode collation sequence
$this->collator = new \Collator("@kf=false");
$m = ($this->db instanceof \PDO) ? "sqliteCreateCollation" : "createCollation";
$this->db->$m("nocase", [$this->collator, "compare"]);
}
public static function requirementsMet(): bool {
@ -68,6 +73,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$this->db->enableExceptions(true);
}
protected function setTimeout(int $msec) {
$this->exec("PRAGMA busy_timeout = $msec");
}
public function __destruct() {
try {
$this->db->close();
@ -100,20 +109,27 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
return (int) $this->query("PRAGMA user_version")->getValue();
}
public function sqlToken(string $token): string {
switch (strtolower($token)) {
case "greatest":
return "max";
default:
return $token;
}
}
public function schemaUpdate(int $to, string $basePath = null): bool {
// turn off foreign keys
$this->exec("PRAGMA foreign_keys = no");
$this->exec("PRAGMA legacy_alter_table = yes");
// run the generic updater
try {
parent::schemaUpdate($to, $basePath);
} catch (\Throwable $e) {
} finally {
// turn foreign keys back on
$this->exec("PRAGMA foreign_keys = yes");
// pass the exception up
throw $e;
$this->exec("PRAGMA legacy_alter_table = no");
}
// turn foreign keys back on
$this->exec("PRAGMA foreign_keys = yes");
return true;
}
@ -158,7 +174,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
}
protected function lock(): bool {
$timeout = (int) $this->query("PRAGMA busy_timeout")->getValue();
$this->setTimeout(0);
try {
$this->exec("BEGIN EXCLUSIVE TRANSACTION");
} finally {
$this->setTimeout($timeout);
}
return true;
}

View file

@ -21,7 +21,9 @@ class PDODriver extends Driver {
}
protected function makeConnection(string $file, string $key) {
$this->db = new \PDO("sqlite:".$file, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$this->db = new \PDO("sqlite:".$file, "", "", [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
}
public function __destruct() {

View file

@ -33,14 +33,14 @@ class Feed {
} else {
$links = $f->reader->find($f->getUrl(), $f->getContent());
if (!$links) {
// work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory
// work around a PicoFeed memory leak
libxml_use_internal_errors(false);
throw new Feed\Exception($url, new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription'));
} else {
$out = $links[0];
}
}
// work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory
// work around a PicoFeed memory leak
libxml_use_internal_errors(false);
return $out;
}
@ -115,10 +115,10 @@ class Feed {
// Some feeds might use a different domain (eg: feedburner), so the site url is
// used instead of the feed's url.
$this->favicon = (new Favicon)->find($feed->siteUrl);
// work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory
// work around a PicoFeed memory leak
libxml_use_internal_errors(false);
} catch (PicoFeedException $e) {
// work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory
// work around a PicoFeed memory leak
libxml_use_internal_errors(false);
throw new Feed\Exception($this->resource->getUrl(), $e);
}

View file

@ -140,7 +140,7 @@ class Lang {
protected function listFiles(): array {
$out = $this->globFiles($this->path."*.php");
// trim the returned file paths to return just the language tag
$out = array_map(function ($file) {
$out = array_map(function($file) {
$file = str_replace(DIRECTORY_SEPARATOR, "/", $file); // we replace the directory separator because we don't use native paths in testing
$file = substr($file, strrpos($file, "/")+1);
return strtolower(substr($file, 0, strrpos($file, ".")));

View file

@ -39,8 +39,13 @@ class Context {
protected function act(string $prop, int $set, $value) {
if ($set) {
if (is_null($value)) {
unset($this->props[$prop]);
$this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop];
} else {
$this->props[$prop] = true;
$this->$prop = $value;
}
return $this;
} else {
return isset($this->props[$prop]);
@ -136,14 +141,14 @@ class Context {
}
public function editions(array $spec = null) {
if ($spec) {
if (isset($spec)) {
$spec = $this->cleanArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function articles(array $spec = null) {
if ($spec) {
if (isset($spec)) {
$spec = $this->cleanArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);

View file

@ -20,6 +20,7 @@ class Query {
protected $qWhere = []; // WHERE clause components
protected $tWhere = []; // WHERE clause type bindings
protected $vWhere = []; // WHERE clause binding values
protected $group = []; // GROUP BY clause components
protected $order = []; // ORDER BY clause components
protected $limit = 0;
protected $offset = 0;
@ -68,6 +69,13 @@ class Query {
return true;
}
public function setGroup(string ...$column): bool {
foreach ($column as $col) {
$this->group[] = $col;
}
return true;
}
public function setOrder(string $order, bool $prepend = false): bool {
if ($prepend) {
array_unshift($this->order, $order);
@ -97,6 +105,7 @@ class Query {
$this->tJoin = [];
$this->vJoin = [];
$this->order = [];
$this->group = [];
$this->setLimit(0, 0);
if (strlen($join)) {
$this->jCTE[] = $join;
@ -167,6 +176,10 @@ class Query {
if (sizeof($this->qWhere)) {
$out .= " WHERE ".implode(" AND ", $this->qWhere);
}
// add any GROUP BY terms
if (sizeof($this->group)) {
$out .= " GROUP BY ".implode(", ", $this->group);
}
// add any ORDER BY terms
if (sizeof($this->order)) {
$out .= " ORDER BY ".implode(", ", $this->order);

View file

@ -97,7 +97,7 @@ class REST {
public function apiMatch(string $url): array {
$map = $this->apis;
// sort the API list so the longest URL prefixes come first
uasort($map, function ($a, $b) {
uasort($map, function($a, $b) {
return (strlen($a['match']) <=> strlen($b['match'])) * -1;
});
// normalize the target URL
@ -270,7 +270,7 @@ class REST {
} else {
// if the host is a domain name or IP address, split it along dots and just perform URL decoding
$host = explode(".", $host);
$host = array_map(function ($segment) {
$host = array_map(function($segment) {
return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment))));
}, $host);
$host = implode(".", $host);

View file

@ -563,7 +563,23 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// perform the fetch
try {
$items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL);
$items = Arsse::$db->articleList(Arsse::$user->id, $c, [
"edition",
"guid",
"id",
"url",
"title",
"author",
"edited_date",
"content",
"media_type",
"media_url",
"subscription",
"unread",
"starred",
"modified_date",
"fingerprint",
]);
} catch (ExceptionInput $e) {
// ID of subscription or folder is not valid
return new EmptyResponse(422);

View file

@ -330,7 +330,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'id' => "FEED:".self::FEED_ALL,
'bare_id' => self::FEED_ALL,
'icon' => "images/folder.png",
'unread' => array_reduce($subs, function ($sum, $value) {
'unread' => array_reduce($subs, function($sum, $value) {
return $sum + $value['unread'];
}, 0), // the sum of all feeds' unread is the total unread
], $tSpecial),
@ -1113,8 +1113,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => (bool) $data['mode']], (new Context)->articles($articles));
break;
case 2: //toggle
$on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(true), Database::LIST_MINIMAL)->getAll(), "id");
$off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(false), Database::LIST_MINIMAL)->getAll(), "id");
$on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(true), ["id"])->getAll(), "id");
$off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(false), ["id"])->getAll(), "id");
if ($off) {
$out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], (new Context)->articles($off));
}
@ -1145,8 +1145,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => !$data['mode']], (new Context)->articles($articles));
break;
case 2: //toggle
$on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(true), Database::LIST_MINIMAL)->getAll(), "id");
$off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(false), Database::LIST_MINIMAL)->getAll(), "id");
$on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(true), ["id"])->getAll(), "id");
$off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(false), ["id"])->getAll(), "id");
if ($off) {
$out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], (new Context)->articles($off));
}
@ -1183,7 +1183,22 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// retrieve the requested articles
$out = [];
foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)) as $article) {
$columns = [
"id",
"guid",
"title",
"url",
"unread",
"starred",
"edited_date",
"subscription",
"subscription_title",
"note",
"content",
"media_url",
"media_type",
];
foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles), $columns) as $article) {
$out[] = [
'id' => (string) $article['id'], // string cast to be consistent with TTRSS
'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null,
@ -1246,7 +1261,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// fetch the list of IDs
$out = [];
try {
foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) {
foreach ($this->fetchArticles($data, ["id"]) as $row) {
$out[] = ['id' => (int) $row['id']];
}
} catch (ExceptionInput $e) {
@ -1267,7 +1282,23 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// retrieve the requested articles
$out = [];
try {
foreach ($this->fetchArticles($data, Database::LIST_FULL) as $article) {
$columns = [
"id",
"guid",
"title",
"url",
"unread",
"starred",
"edited_date",
"published_date",
"subscription",
"subscription_title",
"note",
($data['show_content'] || $data['show_excerpt']) ? "content" : "",
($data['include_attachments']) ? "media_url": "",
($data['include_attachments']) ? "media_type": "",
];
foreach ($this->fetchArticles($data, $columns) as $article) {
$row = [
'id' => (int) $article['id'],
'guid' => $article['guid'] ? "SHA256:".$article['guid'] : "",
@ -1325,7 +1356,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// when paginating the header returns the latest ("first") item ID in the full list; we get this ID here
$data['skip'] = 0;
$data['limit'] = 1;
$firstID = ($this->fetchArticles($data, Database::LIST_MINIMAL)->getRow() ?? ['id' => 0])['id'];
$firstID = ($this->fetchArticles($data, ["id"])->getRow() ?? ['id' => 0])['id'];
} elseif ($data['order_by']=="date_reverse") {
// the "date_reverse" sort order doesn't get a first ID because it's meaningless for ascending-order pagination (pages doesn't go stale)
$firstID = 0;
@ -1346,7 +1377,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
protected function fetchArticles(array $data, int $fields): \JKingWeb\Arsse\Db\Result {
protected function fetchArticles(array $data, array $fields): \JKingWeb\Arsse\Db\Result {
// normalize input
if (is_null($data['feed_id'])) {
throw new Exception("INCORRECT_USAGE");

View file

@ -9,7 +9,6 @@ namespace JKingWeb\Arsse;
use PasswordGenerator\Generator as PassGen;
class User {
public $id = null;
/**

View file

@ -20,6 +20,8 @@ return [
'Driver.Db.SQLite3.Name' => 'SQLite 3',
'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)',
'Driver.Db.PostgreSQL.Name' => 'PostgreSQL',
'Driver.Db.PostgreSQLPDO.Name' => 'PostgreSQL (PDO)',
'Driver.Service.Curl.Name' => 'HTTP (curl)',
'Driver.Service.Internal.Name' => 'Internal',
'Driver.User.Internal.Name' => 'Internal',
@ -120,6 +122,7 @@ return [
'Exception.JKingWeb/Arsse/Db/Exception.fileUnusable' => 'Insufficient permissions to open database file "{0}" for reading or writing',
'Exception.JKingWeb/Arsse/Db/Exception.fileUncreatable' => 'Insufficient permissions to create new database file "{0}"',
'Exception.JKingWeb/Arsse/Db/Exception.fileCorrupt' => 'Database file "{0}" is corrupt or not a valid database',
'Exception.JKingWeb/Arsse/Db/Exception.connectionFailure' => 'Could not connect to {0} database: {1}',
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeInvalid' => 'Prepared statement parameter type "{0}" is invalid',
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeUnknown' => 'Prepared statement parameter type "{0}" is valid, but not implemented',
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeMissing' => 'Prepared statement parameter type for parameter #{0} was not specified',
@ -156,7 +159,7 @@ return [
'Exception.JKingWeb/Arsse/Db/ExceptionInput.subjectMissing' => 'Referenced ID ({id}) in field "{field}" does not exist',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.idMissing' => 'Referenced ID ({id}) in field "{field}" does not exist',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.circularDependence' => 'Referenced ID ({id}) in field "{field}" creates a circular dependence',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.constraintViolation' => 'Specified value in field "{0}" already exists',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.constraintViolation' => 'Specified value in field "{field}" already exists',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineConstraintViolation' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineTypeViolation' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.general' => '{0}',

113
sql/PostgreSQL/0.sql Normal file
View file

@ -0,0 +1,113 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Please consult the SQLite 3 schemata for commented version
create table arsse_meta(
key text primary key,
value text
);
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
);
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)
);
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) without time zone not null default CURRENT_TIMESTAMP, --
unique(owner,name,parent)
);
create table arsse_feeds(
id bigserial primary key,
url text not null,
title text,
favicon text,
source text,
updated timestamp(0) without time zone,
modified timestamp(0) without time zone,
next_fetch timestamp(0) without time zone,
orphaned timestamp(0) without 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)
);
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) without time zone not null default CURRENT_TIMESTAMP,
modified timestamp(0) without 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)
);
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) without time zone,
edited timestamp(0) without time zone,
modified timestamp(0) without 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
);
create table arsse_enclosures(
article bigint not null references arsse_articles(id) on delete cascade,
url text,
type text
);
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) without time zone not null default CURRENT_TIMESTAMP,
primary key(article,subscription)
);
create table arsse_editions(
id bigserial primary key,
article bigint not null references arsse_articles(id) on delete cascade,
modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP
);
create table arsse_categories(
article bigint not null references arsse_articles(id) on delete cascade,
name text
);
insert into arsse_meta(key,value) values('schema_version','1');

33
sql/PostgreSQL/1.sql Normal file
View file

@ -0,0 +1,33 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Please consult the SQLite 3 schemata for commented version
create table arsse_sessions (
id text primary key,
created timestamp(0) without time zone not null default CURRENT_TIMESTAMP,
expires timestamp(0) without time zone not null,
"user" text not null references arsse_users(id) on delete cascade on update cascade
);
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) without time zone not null default CURRENT_TIMESTAMP,
unique(owner,name)
);
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) without time zone not null default CURRENT_TIMESTAMP,
primary key(label,article)
);
alter table arsse_marks add column note text not null default '';
update arsse_meta set value = '2' where key = 'schema_version';

16
sql/PostgreSQL/2.sql Normal file
View file

@ -0,0 +1,16 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Please consult the SQLite 3 schemata for commented version
alter table arsse_users alter column id type text collate "und-x-icu";
alter table arsse_folders alter column name type text collate "und-x-icu";
alter table arsse_feeds alter column title type text collate "und-x-icu";
alter table arsse_subscriptions alter column title type text collate "und-x-icu";
alter table arsse_articles alter column title type text collate "und-x-icu";
alter table arsse_articles alter column author type text collate "und-x-icu";
alter table arsse_categories alter column name type text collate "und-x-icu";
alter table arsse_labels alter column name type text collate "und-x-icu";
update arsse_meta set value = '3' where key = 'schema_version';

11
sql/PostgreSQL/3.sql Normal file
View file

@ -0,0 +1,11 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Please consult the SQLite 3 schemata for commented version
alter table arsse_marks alter column modified drop default;
alter table arsse_marks alter column modified drop not null;
alter table arsse_marks add column touched smallint not null default 0;
update arsse_meta set value = '4' where key = 'schema_version';

View file

@ -5,14 +5,14 @@
-- Make the database WAL-journalled; this is persitent
PRAGMA journal_mode = wal;
-- metadata
create table arsse_meta(
-- application metadata
key text primary key not null, -- metadata key
value text -- metadata value, serialized as a string
);
-- users
create table arsse_users(
-- 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
@ -22,29 +22,32 @@ create table arsse_users(
rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this
);
-- extra user metadata
create table arsse_users_meta(
-- extra user metadata (not currently used and will be removed)
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
create table arsse_folders(
-- folders, used by NextCloud News and Tiny Tiny RSS
-- feed subscriptions may belong to at most one folder;
-- in Tiny Tiny RSS folders may nest
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 text not null default CURRENT_TIMESTAMP, --
modified text not null default CURRENT_TIMESTAMP, -- time at which the folder itself (not its contents) was changed; not currently used
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(
-- newsfeeds, deduplicated
-- users have subscriptions to these feeds in another table
id integer primary key, -- sequence number
url text not null, -- URL of feed
title text, -- default title of feed
title text, -- default title of feed (users can set the title of their subscription to the feed)
favicon text, -- URL of favicon
source text, -- URL of site to which the feed belongs
updated text, -- time at which the feed was last fetched
@ -61,13 +64,13 @@ create table arsse_feeds(
unique(url,username,password) -- a URL with particular credentials should only appear once
);
-- users' subscriptions to newsfeeds, with settings
create table arsse_subscriptions(
-- users' subscriptions to newsfeeds, with settings
id integer primary key, -- sequence number
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 text not null default CURRENT_TIMESTAMP, -- time at which feed was added
modified text not null default CURRENT_TIMESTAMP, -- date at which subscription properties were last modified
modified text not null default CURRENT_TIMESTAMP, -- time at which subscription properties were last modified
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)
@ -75,16 +78,16 @@ create table arsse_subscriptions(
unique(owner,feed) -- a given feed should only appear once for a given owner
);
-- entries in newsfeeds
create table arsse_articles(
-- entries in newsfeeds
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
published text, -- time of original publication
edited text, -- time of last edit
modified text not null default CURRENT_TIMESTAMP, -- date when article properties were last modified
edited text, -- time of last edit by author
modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
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.
@ -92,34 +95,37 @@ create table arsse_articles(
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 text
-- enclosures (attachments) associated with articles
article integer not null references arsse_articles(id) on delete cascade, -- article to which the enclosure belongs
url text, -- URL of the enclosure
type text -- content-type (MIME type) of the enclosure
);
-- users' actions on newsfeed entries
create table arsse_marks(
article integer not null references arsse_articles(id) on delete cascade,
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade,
read boolean not null default 0,
starred boolean not null default 0,
modified text not null default CURRENT_TIMESTAMP,
primary key(article,subscription)
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user
read boolean not null default 0, -- whether the article has been read
starred boolean not null default 0, -- whether the article is starred
modified text not null default CURRENT_TIMESTAMP, -- time at which an article was last modified by a given user
primary key(article,subscription) -- no more than one mark-set per article per user
);
-- IDs for specific editions of articles (required for at least NextCloud News)
create table arsse_editions(
id integer primary key,
article integer not null references arsse_articles(id) on delete cascade,
modified datetime not null default CURRENT_TIMESTAMP
-- IDs for specific editions of articles (required for at least NextCloud News)
-- every time an article is updated by its author, a new unique edition number is assigned
-- with NextCloud News this prevents users from marking as read an article which has been
-- updated since the client state was last refreshed
id integer primary key, -- sequence number
article integer not null references arsse_articles(id) on delete cascade, -- the article of which this is an edition
modified datetime not null default CURRENT_TIMESTAMP -- tiem at which the edition was modified (practically, when it was created)
);
-- author categories associated with newsfeed entries
create table arsse_categories(
article integer not null references arsse_articles(id) on delete cascade,
name text
-- author categories associated with newsfeed entries
-- these are not user-modifiable
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the category
name text -- freeform name of the category
);
-- set version marker

View file

@ -2,16 +2,16 @@
-- 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 (
-- sessions for Tiny Tiny RSS (and possibly others)
id text primary key, -- UUID of session
created text not null default CURRENT_TIMESTAMP, -- Session start timestamp
expires text not null, -- Time at which session is no longer valid
user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session
) without rowid;
-- User-defined article labels for Tiny Tiny RSS
create table arsse_labels (
-- user-defined article labels for Tiny Tiny RSS
id integer primary key, -- numeric ID
owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user
name text not null, -- label text
@ -19,26 +19,29 @@ create table arsse_labels (
unique(owner,name)
);
-- Labels assignments for articles
create table arsse_label_members (
label integer not null references arsse_labels(id) on delete cascade,
article integer not null references arsse_articles(id) on delete cascade,
-- uabels assignments for articles
label integer not null references arsse_labels(id) on delete cascade, -- label ID associated to an article; label IDs belong to a user
article integer not null references arsse_articles(id) on delete cascade, -- article associated to a label
subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed
assigned boolean not null default 1,
modified text not null default CURRENT_TIMESTAMP,
primary key(label,article)
assigned boolean not null default 1, -- whether the association is current, to support soft deletion
modified text not null default CURRENT_TIMESTAMP, -- time at which the association was last made or unmade
primary key(label,article) -- only one association of a given label to a given article
) without rowid;
-- alter marks table to add Tiny Tiny RSS' notes
-- SQLite has limited ALTER TABLE support, so the table must be re-created
-- and its data re-entered; other database systems have a much simpler prodecure
alter table arsse_marks rename to arsse_marks_old;
create table arsse_marks(
article integer not null references arsse_articles(id) on delete cascade,
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade,
read boolean not null default 0,
starred boolean not null default 0,
modified text not null default CURRENT_TIMESTAMP,
note text not null default '',
primary key(article,subscription)
-- users' actions on newsfeed entries
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user
read boolean not null default 0, -- whether the article has been read
starred boolean not null default 0, -- whether the article is starred
modified text not null default CURRENT_TIMESTAMP, -- time at which an article was last modified by a given user
note text not null default '', -- Tiny Tiny RSS freeform user note
primary key(article,subscription) -- no more than one mark-set per article per user
);
insert into arsse_marks(article,subscription,read,starred,modified) select article,subscription,read,starred,modified from arsse_marks_old;
drop table arsse_marks_old;

View file

@ -2,94 +2,106 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Correct collation sequences
-- Correct collation sequences in order for various things to sort case-insensitively
-- SQLite has limited ALTER TABLE support, so the tables must be re-created
-- and their data re-entered; other database systems have a much simpler prodecure
alter table arsse_users rename to arsse_users_old;
create table arsse_users(
id text primary key not null collate nocase,
password text,
name text collate nocase,
avatar_type text,
avatar_data blob,
admin boolean default 0,
rights integer not null default 0
-- users
id text primary key not null collate nocase, -- user id
password text, -- password, salted and hashed; if using external authentication this would be blank
name text collate nocase, -- display name
avatar_type text, -- internal avatar image's MIME content type
avatar_data blob, -- internal avatar image's binary data
admin boolean default 0, -- whether the user is a member of the special "admin" group
rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this
);
insert into arsse_users(id,password,name,avatar_type,avatar_data,admin,rights) select id,password,name,avatar_type,avatar_data,admin,rights from arsse_users_old;
drop table arsse_users_old;
alter table arsse_folders rename to arsse_folders_old;
create table arsse_folders(
id integer primary key,
owner text not null references arsse_users(id) on delete cascade on update cascade,
parent integer references arsse_folders(id) on delete cascade,
name text not null collate nocase,
modified text not null default CURRENT_TIMESTAMP, --
unique(owner,name,parent)
-- folders, used by NextCloud News and Tiny Tiny RSS
-- feed subscriptions may belong to at most one folder;
-- in Tiny Tiny RSS folders may nest
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 collate nocase, -- folder name
modified text not null default CURRENT_TIMESTAMP, -- time at which the folder itself (not its contents) was changed; not currently used
unique(owner,name,parent) -- cannot have multiple folders with the same name under the same parent for the same owner
);
insert into arsse_folders select * from arsse_folders_old;
drop table arsse_folders_old;
alter table arsse_feeds rename to arsse_feeds_old;
create table arsse_feeds(
id integer primary key,
url text not null,
title text collate nocase,
favicon text,
source text,
updated text,
modified text,
next_fetch text,
orphaned text,
etag text not null default '',
err_count integer not null default 0,
err_msg text,
username text not null default '',
password text not null default '',
size integer not null default 0,
scrape boolean not null default 0,
unique(url,username,password)
-- newsfeeds, deduplicated
-- users have subscriptions to these feeds in another table
id integer primary key, -- sequence number
url text not null, -- URL of feed
title text collate nocase, -- default title of feed (users can set the title of their subscription to the feed)
favicon text, -- URL of favicon
source text, -- URL of site to which the feed belongs
updated text, -- time at which the feed was last fetched
modified text, -- time at which the feed last actually changed
next_fetch text, -- time at which the feed should next be fetched
orphaned text, -- time at which the feed last had no subscriptions
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)
size integer not null default 0, -- number of articles in the feed at last fetch
scrape boolean not null default 0, -- whether to use picoFeed's content scraper with this feed
unique(url,username,password) -- a URL with particular credentials should only appear once
);
insert into arsse_feeds select * from arsse_feeds_old;
drop table arsse_feeds_old;
alter table arsse_subscriptions rename to arsse_subscriptions_old;
create table arsse_subscriptions(
id integer primary key,
owner text not null references arsse_users(id) on delete cascade on update cascade,
feed integer not null references arsse_feeds(id) on delete cascade,
added text not null default CURRENT_TIMESTAMP,
modified text not null default CURRENT_TIMESTAMP,
title text collate nocase,
order_type int not null default 0,
pinned boolean not null default 0,
folder integer references arsse_folders(id) on delete cascade,
unique(owner,feed)
-- users' subscriptions to newsfeeds, with settings
id integer primary key, -- sequence number
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 text not null default CURRENT_TIMESTAMP, -- time at which feed was added
modified text not null default CURRENT_TIMESTAMP, -- time at which subscription properties were last modified
title text collate nocase, -- 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
);
insert into arsse_subscriptions select * from arsse_subscriptions_old;
drop table arsse_subscriptions_old;
alter table arsse_articles rename to arsse_articles_old;
create table arsse_articles(
id integer primary key,
feed integer not null references arsse_feeds(id) on delete cascade,
url text,
title text collate nocase,
author text collate nocase,
published text,
edited text,
modified text 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
-- entries in newsfeeds
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 collate nocase, -- article title
author text collate nocase, -- author's name
published text, -- time of original publication
edited text, -- time of last edit by author
modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
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.
);
insert into arsse_articles select * from arsse_articles_old;
drop table arsse_articles_old;
alter table arsse_categories rename to arsse_categories_old;
create table arsse_categories(
article integer not null references arsse_articles(id) on delete cascade,
name text collate nocase
-- author categories associated with newsfeed entries
-- these are not user-modifiable
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the category
name text collate nocase -- freeform name of the category
);
insert into arsse_categories select * from arsse_categories_old;
drop table arsse_categories_old;
@ -97,10 +109,11 @@ drop table arsse_categories_old;
alter table arsse_labels rename to arsse_labels_old;
create table arsse_labels (
id integer primary key,
owner text not null references arsse_users(id) on delete cascade on update cascade,
name text not null collate nocase,
modified text not null default CURRENT_TIMESTAMP,
-- user-defined article labels for Tiny Tiny RSS
id integer primary key, -- numeric ID
owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user
name text not null collate nocase, -- label text
modified text not null default CURRENT_TIMESTAMP, -- time at which the label was last modified
unique(owner,name)
);
insert into arsse_labels select * from arsse_labels_old;

27
sql/SQLite3/3.sql Normal file
View file

@ -0,0 +1,27 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- allow marks to initially have a null date due to changes in how marks are first created
-- and also add a "touched" column to aid in tracking changes during the course of some transactions
alter table arsse_marks rename to arsse_marks_old;
create table arsse_marks(
-- users' actions on newsfeed entries
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user
read boolean not null default 0, -- whether the article has been read
starred boolean not null default 0, -- whether the article is starred
modified text, -- time at which an article was last modified by a given user
note text not null default '', -- Tiny Tiny RSS freeform user note
touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions
primary key(article,subscription) -- no more than one mark-set per article per user
);
insert into arsse_marks select article,subscription,read,starred,modified,note,0 from arsse_marks_old;
drop table arsse_marks_old;
-- reindex anything which uses the nocase collation sequence; it has been replaced with a Unicode collation
reindex nocase;
-- set version marker
pragma user_version = 4;
update arsse_meta set value = '4' where key = 'schema_version';

View file

@ -16,9 +16,8 @@ use Phake;
/** @covers \JKingWeb\Arsse\CLI */
class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp() {
$this->clearData(false);
self::clearData(false);
$this->cli = Phake::partialMock(CLI::class);
Phake::when($this->cli)->logError->thenReturn(null);
}

View file

@ -15,7 +15,7 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest {
public static $path;
public function setUp() {
$this->clearData();
self::clearData();
self::$vfs = vfsStream::setup("root", null, [
'confGood' => '<?php return Array("lang" => "xx");',
'confNotArray' => '<?php return 0;',
@ -35,7 +35,7 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest {
public function tearDown() {
self::$path = null;
self::$vfs = null;
$this->clearData();
self::clearData();
}
public function testLoadDefaultValues() {

View file

@ -4,37 +4,85 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\User\Driver as UserDriver;
use JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Db\Result;
use JKingWeb\Arsse\Test\DatabaseInformation;
use Phake;
trait Setup {
protected $drv;
abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest {
use SeriesMiscellany;
use SeriesMeta;
use SeriesUser;
use SeriesSession;
use SeriesFolder;
use SeriesFeed;
use SeriesSubscription;
use SeriesArticle;
use SeriesLabel;
use SeriesCleanup;
/** @var \JKingWeb\Arsse\Test\DatabaseInformation */
protected static $dbInfo;
/** @var \JKingWeb\Arsse\Db\Driver */
protected static $drv;
protected static $failureReason = "";
protected $primed = false;
public function setUp() {
abstract protected function nextID(string $table): int;
protected function findTraitOfTest(string $test): string {
$class = new \ReflectionClass(self::class);
foreach ($class->getTraits() as $trait) {
if ($trait->hasMethod($test)) {
return $trait->getShortName();
}
}
return $class->getShortName();
}
public static function setUpBeforeClass() {
// establish a clean baseline
$this->clearData();
$this->setConf();
// configure and create the relevant database driver
$this->setUpDriver();
// create the database interface with the suitable driver
Arsse::$db = new Database($this->drv);
static::clearData();
// perform an initial connection to the database to reset its version to zero
// in the case of SQLite this will always be the case (we use a memory database),
// but other engines should clean up from potentially interrupted prior tests
static::$dbInfo = new DatabaseInformation(static::$implementation);
static::setConf();
try {
static::$drv = new static::$dbInfo->driverClass;
} catch (\JKingWeb\Arsse\Db\Exception $e) {
static::$failureReason = $e->getMessage();
return;
}
// wipe the database absolutely clean
(static::$dbInfo->razeFunction)(static::$drv);
// create the database interface with the suitable driver and apply the latest schema
Arsse::$db = new Database(static::$drv);
Arsse::$db->driverSchemaUpdate();
}
public function setUp() {
// get the name of the test's test series
$this->series = $this->findTraitofTest($this->getName());
static::clearData();
static::setConf();
if (strlen(static::$failureReason)) {
$this->markTestSkipped(static::$failureReason);
}
Arsse::$db = new Database(static::$drv);
Arsse::$db->driverSchemaUpdate();
// create a mock user manager
Arsse::$user = Phake::mock(User::class);
Phake::when(Arsse::$user)->authorize->thenReturn(true);
// call the additional setup method if it exists
if (method_exists($this, "setUpSeries")) {
$this->setUpSeries();
}
// call the series-specific setup method
$setUp = "setUp".$this->series;
$this->$setUp();
// prime the database with series data if it hasn't already been done
if (!$this->primed && isset($this->data)) {
$this->primeDatabase($this->data);
@ -42,21 +90,36 @@ trait Setup {
}
public function tearDown() {
// call the additional teardiwn method if it exists
if (method_exists($this, "tearDownSeries")) {
$this->tearDownSeries();
}
// call the series-specific teardown method
$this->series = $this->findTraitofTest($this->getName());
$tearDown = "tearDown".$this->series;
$this->$tearDown();
// clean up
$this->primed = false;
$this->drv = null;
$this->clearData();
// call the database-specific table cleanup function
(static::$dbInfo->truncateFunction)(static::$drv);
// clear state
static::clearData();
}
public function primeDatabase(array $data, \JKingWeb\Arsse\Db\Driver $drv = null): bool {
$drv = $drv ?? $this->drv;
public static function tearDownAfterClass() {
// wipe the database absolutely clean
(static::$dbInfo->razeFunction)(static::$drv);
// clean up
static::$drv = null;
static::$dbInfo = null;
static::$failureReason = "";
static::clearData();
}
public function primeDatabase(array $data): bool {
$drv = static::$drv;
$tr = $drv->begin();
foreach ($data as $table => $info) {
$cols = implode(",", array_keys($info['columns']));
$cols = array_map(function($v) {
return '"'.str_replace('"', '""', $v).'"';
}, array_keys($info['columns']));
$cols = implode(",", $cols);
$bindings = array_values($info['columns']);
$params = implode(",", array_fill(0, sizeof($info['columns']), "?"));
$s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings);
@ -69,21 +132,14 @@ trait Setup {
return true;
}
public function primeFile(string $file, array $data = null): bool {
$data = $data ?? $this->data;
$primed = $this->primed;
$drv = new \JKingWeb\Arsse\Db\SQLite3\Driver($file);
$drv->schemaUpdate(\JKingWeb\Arsse\Database::SCHEMA_VERSION);
$this->primeDatabase($data, $drv);
$this->primed = $primed;
return true;
}
public function compareExpectations(array $expected): bool {
foreach ($expected as $table => $info) {
$cols = implode(",", array_keys($info['columns']));
$cols = array_map(function($v) {
return '"'.str_replace('"', '""', $v).'"';
}, array_keys($info['columns']));
$cols = implode(",", $cols);
$types = $info['columns'];
$data = $this->drv->prepare("SELECT $cols from $table")->run()->getAll();
$data = static::$drv->prepare("SELECT $cols from $table")->run()->getAll();
$cols = array_keys($info['columns']);
foreach ($info['rows'] as $index => $row) {
$this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields");

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Arsse;
@ -13,7 +13,8 @@ use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesArticle {
protected $data = [
protected function setUpSeriesArticle() {
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
@ -260,7 +261,7 @@ trait SeriesArticle {
],
],
];
protected $matches = [
$this->matches = [
[
'id' => 101,
'url' => 'http://example.com/1',
@ -362,114 +363,137 @@ trait SeriesArticle {
'note' => "",
],
];
protected $fields = [
Database::LIST_MINIMAL => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
],
Database::LIST_CONSERVATIVE => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
],
Database::LIST_TYPICAL => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
"content", "media_url", "media_type",
],
Database::LIST_FULL => [
$this->fields = [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
"content", "media_url", "media_type",
"note",
],
];
public function setUpSeries() {
$this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"],];
$this->user = "john.doe@example.net";
}
protected function compareIds(array $exp, Context $c) {
$ids = array_column($ids = Arsse::$db->articleList($this->user, $c)->getAll(), "id");
sort($ids);
sort($exp);
$this->assertEquals($exp, $ids);
protected function tearDownSeriesArticle() {
unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user);
}
public function testRetrieveArticleIdsForEditions() {
$exp = [
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5 => 5,
6 => 6,
7 => 7,
8 => 8,
9 => 9,
10 => 10,
11 => 11,
12 => 12,
13 => 13,
14 => 14,
15 => 15,
16 => 16,
17 => 17,
18 => 18,
19 => 19,
20 => 20,
101 => 101,
102 => 102,
103 => 103,
104 => 104,
105 => 105,
202 => 102,
203 => 103,
204 => 104,
205 => 105,
305 => 105,
1001 => 20,
];
$this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001)));
}
public function testListArticlesCheckingContext() {
$this->user = "john.doe@example.com";
$compareIds = function(array $exp, Context $c) {
$ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id");
sort($ids);
sort($exp);
$this->assertEquals($exp, $ids);
};
// get all items for user
$exp = [1,2,3,4,5,6,7,8,19,20];
$this->compareIds($exp, new Context);
$this->compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)));
$compareIds($exp, new Context);
$compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)));
// get items from a folder tree
$this->compareIds([5,6,7,8], (new Context)->folder(1));
$compareIds([5,6,7,8], (new Context)->folder(1));
// get items from a leaf folder
$this->compareIds([7,8], (new Context)->folder(6));
$compareIds([7,8], (new Context)->folder(6));
// get items from a non-leaf folder without descending
$this->compareIds([1,2,3,4], (new Context)->folderShallow(0));
$this->compareIds([5,6], (new Context)->folderShallow(1));
$compareIds([1,2,3,4], (new Context)->folderShallow(0));
$compareIds([5,6], (new Context)->folderShallow(1));
// get items from a single subscription
$exp = [19,20];
$this->compareIds($exp, (new Context)->subscription(5));
$compareIds($exp, (new Context)->subscription(5));
// get un/read items from a single subscription
$this->compareIds([20], (new Context)->subscription(5)->unread(true));
$this->compareIds([19], (new Context)->subscription(5)->unread(false));
$compareIds([20], (new Context)->subscription(5)->unread(true));
$compareIds([19], (new Context)->subscription(5)->unread(false));
// get starred articles
$this->compareIds([1,20], (new Context)->starred(true));
$this->compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false));
$this->compareIds([1], (new Context)->starred(true)->unread(false));
$this->compareIds([], (new Context)->starred(true)->unread(false)->subscription(5));
$compareIds([1,20], (new Context)->starred(true));
$compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false));
$compareIds([1], (new Context)->starred(true)->unread(false));
$compareIds([], (new Context)->starred(true)->unread(false)->subscription(5));
// get items relative to edition
$this->compareIds([19], (new Context)->subscription(5)->latestEdition(999));
$this->compareIds([19], (new Context)->subscription(5)->latestEdition(19));
$this->compareIds([20], (new Context)->subscription(5)->oldestEdition(999));
$this->compareIds([20], (new Context)->subscription(5)->oldestEdition(1001));
$compareIds([19], (new Context)->subscription(5)->latestEdition(999));
$compareIds([19], (new Context)->subscription(5)->latestEdition(19));
$compareIds([20], (new Context)->subscription(5)->oldestEdition(999));
$compareIds([20], (new Context)->subscription(5)->oldestEdition(1001));
// get items relative to article ID
$this->compareIds([1,2,3], (new Context)->latestArticle(3));
$this->compareIds([19,20], (new Context)->oldestArticle(19));
$compareIds([1,2,3], (new Context)->latestArticle(3));
$compareIds([19,20], (new Context)->oldestArticle(19));
// get items relative to (feed) modification date
$exp = [2,4,6,8,20];
$this->compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z"));
$this->compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z"));
$compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z"));
$compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z"));
$exp = [1,3,5,7,19];
$this->compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z"));
$this->compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z"));
$compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z"));
$compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z"));
// get items relative to (user) modification date (both marks and labels apply)
$this->compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z"));
$this->compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z"));
$this->compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z"));
$this->compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z"));
$compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z"));
$compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z"));
$compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z"));
$compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z"));
// paged results
$this->compareIds([1], (new Context)->limit(1));
$this->compareIds([2], (new Context)->limit(1)->oldestEdition(1+1));
$this->compareIds([3], (new Context)->limit(1)->oldestEdition(2+1));
$this->compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1));
$compareIds([1], (new Context)->limit(1));
$compareIds([2], (new Context)->limit(1)->oldestEdition(1+1));
$compareIds([3], (new Context)->limit(1)->oldestEdition(2+1));
$compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1));
// reversed results
$this->compareIds([20], (new Context)->reverse(true)->limit(1));
$this->compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1));
$this->compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1));
$this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1));
$compareIds([20], (new Context)->reverse(true)->limit(1));
$compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1));
$compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1));
$compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1));
// get articles by label ID
$this->compareIds([1,19], (new Context)->label(1));
$this->compareIds([1,5,20], (new Context)->label(2));
$compareIds([1,19], (new Context)->label(1));
$compareIds([1,5,20], (new Context)->label(2));
// get articles by label name
$this->compareIds([1,19], (new Context)->labelName("Interesting"));
$this->compareIds([1,5,20], (new Context)->labelName("Fascinating"));
$compareIds([1,19], (new Context)->labelName("Interesting"));
$compareIds([1,5,20], (new Context)->labelName("Fascinating"));
// get articles with any or no label
$this->compareIds([1,5,8,19,20], (new Context)->labelled(true));
$this->compareIds([2,3,4,6,7], (new Context)->labelled(false));
$compareIds([1,5,8,19,20], (new Context)->labelled(true));
$compareIds([2,3,4,6,7], (new Context)->labelled(false));
// get a specific article or edition
$this->compareIds([20], (new Context)->article(20));
$this->compareIds([20], (new Context)->edition(1001));
$compareIds([20], (new Context)->article(20));
$compareIds([20], (new Context)->edition(1001));
// get multiple specific articles or editions
$this->compareIds([1,20], (new Context)->articles([1,20,50]));
$this->compareIds([1,20], (new Context)->editions([1,1001,50]));
$compareIds([1,20], (new Context)->articles([1,20,50]));
$compareIds([1,20], (new Context)->editions([1,1001,50]));
// get articles base on whether or not they have notes
$this->compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false));
$this->compareIds([2], (new Context)->annotated(true));
$compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false));
$compareIds([2], (new Context)->annotated(true));
// get specific starred articles
$this->compareIds([1], (new Context)->articles([1,2,3])->starred(true));
$this->compareIds([2,3], (new Context)->articles([1,2,3])->starred(false));
$compareIds([1], (new Context)->articles([1,2,3])->starred(true));
$compareIds([2,3], (new Context)->articles([1,2,3])->starred(false));
}
public function testListArticlesOfAMissingFolder() {
@ -484,17 +508,15 @@ trait SeriesArticle {
public function testListArticlesCheckingProperties() {
$this->user = "john.doe@example.org";
$this->assertResult($this->matches, Arsse::$db->articleList($this->user));
// check that the different fieldset groups return the expected columns
foreach ($this->fields as $constant => $columns) {
$test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $constant)->getRow());
sort($columns);
sort($test);
$this->assertEquals($columns, $test, "Fields do not match expectation for verbosity $constant");
foreach ($this->fields as $column) {
$test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), [$column])->getRow());
$this->assertEquals([$column], $test);
}
// check that an unknown fieldset produces an exception
$this->assertException("constantUnknown");
Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX);
// check that an unknown field is silently ignored
$columns = array_merge($this->fields, ["unknown_column", "bogus_column"]);
$test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $columns)->getRow());
$this->assertEquals($this->fields, $test);
}
public function testListArticlesWithoutAuthority() {
@ -503,6 +525,10 @@ trait SeriesArticle {
Arsse::$db->articleList($this->user);
}
public function testMarkNothing() {
$this->assertSame(0, Arsse::$db->articleMark($this->user, []));
}
public function testMarkAllArticlesUnread() {
Arsse::$db->articleMark($this->user, ['read'=>false]);
$now = Date::transform(time(), "sql");
@ -746,6 +772,12 @@ trait SeriesArticle {
$this->compareExpectations($state);
}
public function testMarkMultipleMissingEditions() {
$this->assertSame(0, Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([500,501])));
$state = $this->primeExpectations($this->data, $this->checkTables);
$this->compareExpectations($state);
}
public function testMarkMultipleEditionsUnread() {
Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,1001]));
$now = Date::transform(time(), "sql");
@ -915,7 +947,7 @@ trait SeriesArticle {
}
public function testListTheLabelsOfAnArticle() {
$this->assertEquals([2,1], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
$this->assertEquals([1,2], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
$this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5));
$this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2));
$this->assertEquals(["Fascinating","Interesting"], Arsse::$db->articleLabelsGet("john.doe@example.com", 1, true));

View file

@ -4,13 +4,13 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use Phake;
trait SeriesCleanup {
public function setUpSeries() {
protected function setUpSeriesCleanup() {
// set up the configuration
Arsse::$conf->import([
'userSessionTimeout' => "PT1H",
@ -135,6 +135,10 @@ trait SeriesCleanup {
];
}
protected function tearDownSeriesCleanup() {
unset($this->data);
}
public function testCleanUpOrphanedFeeds() {
Arsse::$db->feedCleanup();
$now = gmdate("Y-m-d H:i:s");

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Feed;
@ -12,26 +12,7 @@ use JKingWeb\Arsse\Feed\Exception as FeedException;
use Phake;
trait SeriesFeed {
protected $matches = [
[
'id' => 4,
'edited' => '2000-01-04 00:00:00',
'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180',
'url_title_hash' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8',
'url_content_hash' => 'f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3',
'title_content_hash' => 'ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9',
],
[
'id' => 5,
'edited' => '2000-01-05 00:00:00',
'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41',
'url_title_hash' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022',
'url_content_hash' => '834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900',
'title_content_hash' => '43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba',
],
];
public function setUpSeries() {
protected function setUpSeriesFeed() {
// set up the test data
$past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute"));
$future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute"));
@ -163,6 +144,28 @@ trait SeriesFeed {
]
],
];
$this->matches = [
[
'id' => 4,
'edited' => '2000-01-04 00:00:00',
'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180',
'url_title_hash' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8',
'url_content_hash' => 'f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3',
'title_content_hash' => 'ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9',
],
[
'id' => 5,
'edited' => '2000-01-05 00:00:00',
'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41',
'url_title_hash' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022',
'url_content_hash' => '834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900',
'title_content_hash' => '43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba',
],
];
}
protected function tearDownSeriesFeed() {
unset($this->data, $this->matches);
}
public function testListLatestItems() {

View file

@ -4,13 +4,14 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use Phake;
trait SeriesFolder {
protected $data = [
protected function setUpSeriesFolder() {
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
@ -49,6 +50,11 @@ trait SeriesFolder {
]
],
];
}
protected function tearDownSeriesFolder() {
unset($this->data);
}
public function testAddARootFolder() {
$user = "john.doe@example.com";

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Context;
@ -12,7 +12,8 @@ use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesLabel {
protected $data = [
protected function setUpSeriesLabel() {
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
@ -240,13 +241,15 @@ trait SeriesLabel {
],
],
];
public function setUpSeries() {
$this->checkLabels = ['arsse_labels' => ["id","owner","name"]];
$this->checkMembers = ['arsse_label_members' => ["label","article","subscription","assigned"]];
$this->user = "john.doe@example.com";
}
protected function tearDownSeriesLabel() {
unset($this->data, $this->checkLabels, $this->checkMembers, $this->user);
}
public function testAddALabel() {
$user = "john.doe@example.com";
$labelID = $this->nextID("arsse_labels");

View file

@ -4,13 +4,14 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse;
trait SeriesMeta {
protected $dataBare = [
protected function setUpSeriesMeta() {
$dataBare = [
'arsse_meta' => [
'columns' => [
'key' => 'str',
@ -22,14 +23,16 @@ trait SeriesMeta {
],
],
];
public function setUpSeries() {
// the schema_version key is a special case, and to avoid jumping through hoops for every test we deal with it now
$this->data = $this->dataBare;
$this->data = $dataBare;
// as far as tests are concerned the schema version is part of the expectations primed into the database
array_unshift($this->data['arsse_meta']['rows'], ['schema_version', "".Database::SCHEMA_VERSION]);
// but it's already been inserted by the driver, so we prime without it
$this->primeDatabase($this->dataBare);
$this->primeDatabase($dataBare);
}
protected function tearDownSeriesMeta() {
unset($this->data);
}
public function testAddANewValue() {

View file

@ -4,12 +4,21 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
trait SeriesMiscellany {
protected function setUpSeriesMiscellany() {
static::setConf([
'dbDriver' => static::$dbInfo->driverClass,
]);
}
protected function tearDownSeriesMiscellany() {
}
public function testListDrivers() {
$exp = [
'JKingWeb\\Arsse\\Db\\SQLite3\\Driver' => Arsse::$lang->msg("Driver.Db.SQLite3.Name"),
@ -18,11 +27,13 @@ trait SeriesMiscellany {
}
public function testInitializeDatabase() {
$d = new Database();
(static::$dbInfo->razeFunction)(static::$drv);
$d = new Database(true);
$this->assertSame(Database::SCHEMA_VERSION, $d->driverSchemaVersion());
}
public function testManuallyInitializeDatabase() {
(static::$dbInfo->razeFunction)(static::$drv);
$d = new Database(false);
$this->assertSame(0, $d->driverSchemaVersion());
$this->assertTrue($d->driverSchemaUpdate());
@ -31,7 +42,6 @@ trait SeriesMiscellany {
}
public function testCheckCharacterSetAcceptability() {
$d = new Database();
$this->assertInternalType("bool", $d->driverCharsetAcceptable());
$this->assertInternalType("bool", Arsse::$db->driverCharsetAcceptable());
}
}

View file

@ -4,16 +4,16 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesSession {
public function setUpSeries() {
protected function setUpSeriesSession() {
// set up the configuration
Arsse::$conf->import([
static::setConf([
'userSessionTimeout' => "PT1H",
'userSessionLifetime' => "PT24H",
]);
@ -51,6 +51,10 @@ trait SeriesSession {
];
}
protected function tearDownSeriesSession() {
unset($this->data);
}
public function testResumeAValidSession() {
$exp1 = [
'id' => "80fa94c1a11f11e78667001e673b2560",

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Test\Database;
@ -12,7 +12,8 @@ use JKingWeb\Arsse\Feed\Exception as FeedException;
use Phake;
trait SeriesSubscription {
protected $data = [
public function setUpSeriesSubscription() {
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
@ -106,18 +107,20 @@ trait SeriesSubscription {
]
],
];
public function setUpSeries() {
$this->data['arsse_feeds']['rows'] = [
[1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''],
[2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'],
[3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''],
];
// initialize a partial mock of the Database object to later manipulate the feedUpdate method
Arsse::$db = Phake::partialMock(Database::class, $this->drv);
Arsse::$db = Phake::partialMock(Database::class, static::$drv);
$this->user = "john.doe@example.com";
}
protected function tearDownSeriesSubscription() {
unset($this->data, $this->user);
}
public function testAddASubscriptionToAnExistingFeed() {
$url = "http://example.com/feed1";
$subID = $this->nextID("arsse_subscriptions");

View file

@ -4,14 +4,15 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Driver as UserDriver;
use Phake;
trait SeriesUser {
protected $data = [
protected function setUpSeriesUser() {
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
@ -26,6 +27,11 @@ trait SeriesUser {
],
],
];
}
protected function tearDownSeriesUser() {
unset($this->data);
}
public function testCheckThatAUserExists() {
$this->assertTrue(Arsse::$db->userExists("jane.doe@example.com"));

View file

@ -0,0 +1,398 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Db\Result;
use JKingWeb\Arsse\Test\DatabaseInformation;
abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $dbInfo;
protected static $interface;
protected $drv;
protected $create;
protected $lock;
protected $setVersion;
protected static $conf = [
'dbTimeoutExec' => 0.5,
'dbSQLite3Timeout' => 0,
//'dbSQLite3File' => "(temporary file)",
];
public static function setUpBeforeClass() {
// establish a clean baseline
static::clearData();
static::$dbInfo = new DatabaseInformation(static::$implementation);
static::setConf(static::$conf);
static::$interface = (static::$dbInfo->interfaceConstructor)();
}
public function setUp() {
self::clearData();
self::setConf(static::$conf);
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
}
// completely clear the database and ensure the schema version can easily be altered
(static::$dbInfo->razeFunction)(static::$interface, [
"CREATE TABLE arsse_meta(key varchar(255) primary key not null, value text)",
"INSERT INTO arsse_meta(key,value) values('schema_version','0')",
]);
// construct a fresh driver for each test
$this->drv = new static::$dbInfo->driverClass;
}
public function tearDown() {
// deconstruct the driver
unset($this->drv);
self::clearData();
}
public static function tearDownAfterClass() {
if (static::$interface) {
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
}
static::$interface = null;
static::$dbInfo = null;
self::clearData();
}
protected function exec($q): bool {
// PDO implementation
$q = (!is_array($q)) ? [$q] : $q;
foreach ($q as $query) {
static::$interface->exec((string) $query);
}
return true;
}
protected function query(string $q) {
// PDO implementation
return static::$interface->query($q)->fetchColumn();
}
# TESTS
public function testFetchDriverName() {
$class = get_class($this->drv);
$this->assertTrue(strlen($class::driverName()) > 0);
}
public function testFetchSchemaId() {
$class = get_class($this->drv);
$this->assertTrue(strlen($class::schemaID()) > 0);
}
public function testCheckCharacterSetAcceptability() {
$this->assertTrue($this->drv->charsetAcceptable());
}
public function testTranslateAToken() {
$this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest"));
$this->assertSame("distinct", $this->drv->sqlToken("distinct"));
}
public function testExecAValidStatement() {
$this->assertTrue($this->drv->exec($this->create));
}
public function testExecAnInvalidStatement() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->exec("And the meek shall inherit the earth...");
}
public function testExecMultipleStatements() {
$this->assertTrue($this->drv->exec("$this->create; INSERT INTO arsse_test(id) values(2112)"));
$this->assertEquals(2112, $this->query("SELECT id from arsse_test"));
}
public function testExecTimeout() {
$this->exec($this->create);
$this->exec($this->lock);
$this->assertException("general", "Db", "ExceptionTimeout");
$lock = is_array($this->lock) ? implode("; ", $this->lock) : $this->lock;
$this->drv->exec($lock);
}
public function testExecConstraintViolation() {
$this->drv->exec("CREATE TABLE arsse_test(id varchar(255) not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO arsse_test default values");
}
public function testExecTypeViolation() {
$this->drv->exec($this->create);
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO arsse_test(id) values('ook')");
}
public function testMakeAValidQuery() {
$this->assertInstanceOf(Result::class, $this->drv->query("SELECT 1"));
}
public function testMakeAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->query("Apollo was astonished; Dionysus thought me mad");
}
public function testQueryTimeout() {
$this->exec($this->create);
$this->exec($this->lock);
$this->assertException("general", "Db", "ExceptionTimeout");
$lock = is_array($this->lock) ? implode("; ", $this->lock) : $this->lock;
$this->drv->exec($lock);
}
public function testQueryConstraintViolation() {
$this->drv->exec("CREATE TABLE arsse_test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO arsse_test default values");
}
public function testQueryTypeViolation() {
$this->drv->exec($this->create);
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO arsse_test(id) values('ook')");
}
public function testPrepareAValidQuery() {
$s = $this->drv->prepare("SELECT ?, ?", "int", "int");
$this->assertInstanceOf(Statement::class, $s);
}
public function testPrepareAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$s = $this->drv->prepare("This is an invalid query", "int", "int")->run();
}
public function testCreateASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
}
public function testReleaseASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointRelease());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointRelease();
}
public function testUndoASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointUndo());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointUndo();
}
public function testManipulateSavepoints() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertEquals(5, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointUndo(3));
$this->assertFalse($this->drv->savepointRelease(4));
$this->assertEquals(6, $this->drv->savepointCreate());
$this->assertFalse($this->drv->savepointRelease(5));
$this->assertTrue($this->drv->savepointRelease(6));
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertException("savepointStale", "Db");
$this->drv->savepointRelease(2);
}
public function testManipulateSavepointsSomeMore() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertFalse($this->drv->savepointUndo(3));
$this->assertException("savepointStale", "Db");
$this->drv->savepointUndo(2);
}
public function testBeginATransaction() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
}
public function testCommitATransaction() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->query($select));
}
public function testRollbackATransaction() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
}
public function testBeginChainedTransactions() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
}
public function testCommitChainedTransactions() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2->commit();
$this->assertEquals(0, $this->query($select));
$tr1->commit();
$this->assertEquals(2, $this->query($select));
}
public function testCommitChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr1->commit();
$this->assertEquals(2, $this->query($select));
$tr2->commit();
}
public function testRollbackChainedTransactions() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
}
public function testRollbackChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
}
public function testPartiallyRollbackChainedTransactions() {
$select = "SELECT count(*) FROM arsse_test";
$insert = "INSERT INTO arsse_test default values";
$this->drv->exec($this->create);
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->query($select));
$tr1->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->query($select));
}
public function testFetchSchemaVersion() {
$this->assertSame(0, $this->drv->schemaVersion());
$this->drv->exec(str_replace("#", "1", $this->setVersion));
$this->assertSame(1, $this->drv->schemaVersion());
$this->drv->exec(str_replace("#", "2", $this->setVersion));
$this->assertSame(2, $this->drv->schemaVersion());
// SQLite is unaffected by the removal of the metadata table; other backends are
// in neither case should a query for the schema version produce an error, however
$this->exec("DROP TABLE IF EXISTS arsse_meta");
$exp = (static::$dbInfo->backend == "SQLite 3") ? 2 : 0;
$this->assertSame($exp, $this->drv->schemaVersion());
}
public function testLockTheDatabase() {
// PostgreSQL doesn't actually lock the whole database, only the metadata table
// normally the application will first query this table to ensure the schema version is correct,
// so the effect is usually the same
$this->drv->savepointCreate(true);
$this->assertException();
$this->exec($this->lock);
}
public function testUnlockTheDatabase() {
$this->drv->savepointCreate(true);
$this->drv->savepointRelease();
$this->drv->savepointCreate(true);
$this->drv->savepointUndo();
$this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion)));
}
}

View file

@ -0,0 +1,137 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db;
use JKingWeb\Arsse\Db\Result;
use JKingWeb\Arsse\Test\DatabaseInformation;
abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $dbInfo;
protected static $interface;
protected $resultClass;
protected $stringOutput;
abstract protected function makeResult(string $q): array;
public static function setUpBeforeClass() {
// establish a clean baseline
static::clearData();
static::$dbInfo = new DatabaseInformation(static::$implementation);
static::setConf();
static::$interface = (static::$dbInfo->interfaceConstructor)();
}
public function setUp() {
self::clearData();
self::setConf();
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
}
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
$this->resultClass = static::$dbInfo->resultClass;
$this->stringOutput = static::$dbInfo->stringOutput;
}
public function tearDown() {
self::clearData();
}
public static function tearDownAfterClass() {
if (static::$interface) {
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
}
static::$interface = null;
static::$dbInfo = null;
self::clearData();
}
public function testConstructResult() {
$this->assertInstanceOf(Result::class, new $this->resultClass(...$this->makeResult("SELECT 1")));
}
public function testGetChangeCountAndLastInsertId() {
$this->makeResult(static::$createMeta);
$r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_meta(key,value) values('test', 1)"));
$this->assertSame(1, $r->changes());
$this->assertSame(0, $r->lastId());
}
public function testGetChangeCountAndLastInsertIdBis() {
$this->makeResult(static::$createTest);
$r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_test default values"));
$this->assertSame(1, $r->changes());
$this->assertSame(1, $r->lastId());
$r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_test default values"));
$this->assertSame(1, $r->changes());
$this->assertSame(2, $r->lastId());
}
public function testIterateOverResults() {
$exp = [0 => 1, 1 => 2, 2 => 3];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
foreach (new $this->resultClass(...$this->makeResult("SELECT 1 as col union select 2 as col union select 3 as col")) as $index => $row) {
$rows[$index] = $row['col'];
}
$this->assertSame($exp, $rows);
}
public function testIterateOverResultsTwice() {
$exp = [0 => 1, 1 => 2, 2 => 3];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
$result = new $this->resultClass(...$this->makeResult("SELECT 1 as col union select 2 as col union select 3 as col"));
foreach ($result as $index => $row) {
$rows[$index] = $row['col'];
}
$this->assertSame($exp, $rows);
$this->assertException("resultReused", "Db");
foreach ($result as $row) {
$rows[] = $row['col'];
}
}
public function testGetSingleValues() {
$exp = [1867, 1970, 2112];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
$test = new $this->resultClass(...$this->makeResult("SELECT 1867 as year union all select 1970 as year union all select 2112 as year"));
$this->assertSame($exp[0], $test->getValue());
$this->assertSame($exp[1], $test->getValue());
$this->assertSame($exp[2], $test->getValue());
$this->assertSame(null, $test->getValue());
}
public function testGetFirstValuesOnly() {
$exp = [1867, 1970, 2112];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
$test = new $this->resultClass(...$this->makeResult("SELECT 1867 as year, 19 as century union all select 1970 as year, 20 as century union all select 2112 as year, 22 as century"));
$this->assertSame($exp[0], $test->getValue());
$this->assertSame($exp[1], $test->getValue());
$this->assertSame($exp[2], $test->getValue());
$this->assertSame(null, $test->getValue());
}
public function testGetRows() {
$exp = [
['album' => '2112', 'track' => '2112'],
['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
];
$test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"));
$this->assertSame($exp[0], $test->getRow());
$this->assertSame($exp[1], $test->getRow());
$this->assertSame(null, $test->getRow());
}
public function testGetAllRows() {
$exp = [
['album' => '2112', 'track' => '2112'],
['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
];
$test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"));
$this->assertEquals($exp, $test->getAll());
}
}

View file

@ -0,0 +1,333 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Test\DatabaseInformation;
abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $dbInfo;
protected static $interface;
protected $statementClass;
protected $stringOutput;
abstract protected function makeStatement(string $q, array $types = []): array;
abstract protected function decorateTypeSyntax(string $value, string $type): string;
public static function setUpBeforeClass() {
// establish a clean baseline
static::clearData();
static::$dbInfo = new DatabaseInformation(static::$implementation);
static::setConf();
static::$interface = (static::$dbInfo->interfaceConstructor)();
}
public function setUp() {
self::clearData();
self::setConf();
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
}
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
$this->statementClass = static::$dbInfo->statementClass;
$this->stringOutput = static::$dbInfo->stringOutput;
}
public function tearDown() {
self::clearData();
}
public static function tearDownAfterClass() {
if (static::$interface) {
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
}
static::$interface = null;
static::$dbInfo = null;
self::clearData();
}
public function testConstructStatement() {
$this->assertInstanceOf(Statement::class, new $this->statementClass(...$this->makeStatement("SELECT ? as value")));
}
/** @dataProvider provideBindings */
public function testBindATypedValue($value, string $type, string $exp) {
if ($exp=="null") {
$query = "SELECT (? is null) as pass";
} else {
$query = "SELECT ($exp = ?) as pass";
}
$typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
$this->assertTrue((bool) $act);
}
/** @dataProvider provideBinaryBindings */
public function testHandleBinaryData($value, string $type, string $exp) {
if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) {
$this->markTestSkipped("Correct handling of binary data with PostgreSQL is currently unknown");
}
if ($exp=="null") {
$query = "SELECT (? is null) as pass";
} else {
$query = "SELECT ($exp = ?) as pass";
}
$typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
$this->assertTrue((bool) $act);
}
public function testBindMissingValue() {
$s = new $this->statementClass(...$this->makeStatement("SELECT ? as value", ["int"]));
$val = $s->runArray()->getRow()['value'];
$this->assertSame(null, $val);
}
public function testBindMultipleValues() {
$exp = [
'one' => 1,
'two' => 2,
];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
$s = new $this->statementClass(...$this->makeStatement("SELECT ? as one, ? as two", ["int", "int"]));
$val = $s->runArray([1,2])->getRow();
$this->assertSame($exp, $val);
}
public function testBindRecursively() {
$exp = [
'one' => 1,
'two' => 2,
'three' => 3,
'four' => 4,
];
$exp = $this->stringOutput ? $this->stringify($exp) : $exp;
$s = new $this->statementClass(...$this->makeStatement("SELECT ? as one, ? as two, ? as three, ? as four", ["int", ["int", "int"], "int"]));
$val = $s->runArray([1, [2, 3], 4])->getRow();
$this->assertSame($exp, $val);
}
public function testBindWithoutType() {
$this->assertException("paramTypeMissing", "Db");
$s = new $this->statementClass(...$this->makeStatement("SELECT ? as value", []));
$s->runArray([1]);
}
public function testViolateConstraint() {
(new $this->statementClass(...$this->makeStatement("CREATE TABLE if not exists arsse_meta(key varchar(255) primary key not null, value text)")))->run();
$s = new $this->statementClass(...$this->makeStatement("INSERT INTO arsse_meta(key) values(?)", ["str"]));
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$s->runArray([null]);
}
public function testMismatchTypes() {
(new $this->statementClass(...$this->makeStatement("CREATE TABLE if not exists arsse_feeds(id integer primary key not null, url text not null)")))->run();
$s = new $this->statementClass(...$this->makeStatement("INSERT INTO arsse_feeds(id,url) values(?,?)", ["str", "str"]));
$this->assertException("typeViolation", "Db", "ExceptionInput");
$s->runArray(['ook', 'eek']);
}
public function provideBindings() {
$dateMutable = new \DateTime("Noon Today", new \DateTimezone("America/Toronto"));
$dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto"));
$dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC"));
$tests = [
'Null as integer' => [null, "integer", "null"],
'Null as float' => [null, "float", "null"],
'Null as string' => [null, "string", "null"],
'Null as datetime' => [null, "datetime", "null"],
'Null as boolean' => [null, "boolean", "null"],
'Null as strict integer' => [null, "strict integer", "0"],
'Null as strict float' => [null, "strict float", "0.0"],
'Null as strict string' => [null, "strict string", "''"],
'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"],
'Null as strict boolean' => [null, "strict boolean", "0"],
'True as integer' => [true, "integer", "1"],
'True as float' => [true, "float", "1.0"],
'True as string' => [true, "string", "'1'"],
'True as datetime' => [true, "datetime", "null"],
'True as boolean' => [true, "boolean", "1"],
'True as strict integer' => [true, "strict integer", "1"],
'True as strict float' => [true, "strict float", "1.0"],
'True as strict string' => [true, "strict string", "'1'"],
'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"],
'True as strict boolean' => [true, "strict boolean", "1"],
'False as integer' => [false, "integer", "0"],
'False as float' => [false, "float", "0.0"],
'False as string' => [false, "string", "''"],
'False as datetime' => [false, "datetime", "null"],
'False as boolean' => [false, "boolean", "0"],
'False as strict integer' => [false, "strict integer", "0"],
'False as strict float' => [false, "strict float", "0.0"],
'False as strict string' => [false, "strict string", "''"],
'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"],
'False as strict boolean' => [false, "strict boolean", "0"],
'Integer as integer' => [2112, "integer", "2112"],
'Integer as float' => [2112, "float", "2112.0"],
'Integer as string' => [2112, "string", "'2112'"],
'Integer as datetime' => [2112, "datetime", "'1970-01-01 00:35:12'"],
'Integer as boolean' => [2112, "boolean", "1"],
'Integer as strict integer' => [2112, "strict integer", "2112"],
'Integer as strict float' => [2112, "strict float", "2112.0"],
'Integer as strict string' => [2112, "strict string", "'2112'"],
'Integer as strict datetime' => [2112, "strict datetime", "'1970-01-01 00:35:12'"],
'Integer as strict boolean' => [2112, "strict boolean", "1"],
'Integer zero as integer' => [0, "integer", "0"],
'Integer zero as float' => [0, "float", "0.0"],
'Integer zero as string' => [0, "string", "'0'"],
'Integer zero as datetime' => [0, "datetime", "'1970-01-01 00:00:00'"],
'Integer zero as boolean' => [0, "boolean", "0"],
'Integer zero as strict integer' => [0, "strict integer", "0"],
'Integer zero as strict float' => [0, "strict float", "0.0"],
'Integer zero as strict string' => [0, "strict string", "'0'"],
'Integer zero as strict datetime' => [0, "strict datetime", "'1970-01-01 00:00:00'"],
'Integer zero as strict boolean' => [0, "strict boolean", "0"],
'Float as integer' => [2112.5, "integer", "2112"],
'Float as float' => [2112.5, "float", "2112.5"],
'Float as string' => [2112.5, "string", "'2112.5'"],
'Float as datetime' => [2112.5, "datetime", "'1970-01-01 00:35:12'"],
'Float as boolean' => [2112.5, "boolean", "1"],
'Float as strict integer' => [2112.5, "strict integer", "2112"],
'Float as strict float' => [2112.5, "strict float", "2112.5"],
'Float as strict string' => [2112.5, "strict string", "'2112.5'"],
'Float as strict datetime' => [2112.5, "strict datetime", "'1970-01-01 00:35:12'"],
'Float as strict boolean' => [2112.5, "strict boolean", "1"],
'Float zero as integer' => [0.0, "integer", "0"],
'Float zero as float' => [0.0, "float", "0.0"],
'Float zero as string' => [0.0, "string", "'0'"],
'Float zero as datetime' => [0.0, "datetime", "'1970-01-01 00:00:00'"],
'Float zero as boolean' => [0.0, "boolean", "0"],
'Float zero as strict integer' => [0.0, "strict integer", "0"],
'Float zero as strict float' => [0.0, "strict float", "0.0"],
'Float zero as strict string' => [0.0, "strict string", "'0'"],
'Float zero as strict datetime' => [0.0, "strict datetime", "'1970-01-01 00:00:00'"],
'Float zero as strict boolean' => [0.0, "strict boolean", "0"],
'ASCII string as integer' => ["Random string", "integer", "0"],
'ASCII string as float' => ["Random string", "float", "0.0"],
'ASCII string as string' => ["Random string", "string", "'Random string'"],
'ASCII string as datetime' => ["Random string", "datetime", "null"],
'ASCII string as boolean' => ["Random string", "boolean", "1"],
'ASCII string as strict integer' => ["Random string", "strict integer", "0"],
'ASCII string as strict float' => ["Random string", "strict float", "0.0"],
'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"],
'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"],
'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"],
'UTF-8 string as integer' => ["\u{e9}", "integer", "0"],
'UTF-8 string as float' => ["\u{e9}", "float", "0.0"],
'UTF-8 string as string' => ["\u{e9}", "string", "char(233)"],
'UTF-8 string as datetime' => ["\u{e9}", "datetime", "null"],
'UTF-8 string as boolean' => ["\u{e9}", "boolean", "1"],
'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"],
'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0"],
'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"],
'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"],
'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"],
'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"],
'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0"],
'ISO 8601 string as string' => ["2017-01-09T13:11:17", "string", "'2017-01-09T13:11:17'"],
'ISO 8601 string as datetime' => ["2017-01-09T13:11:17", "datetime", "'2017-01-09 13:11:17'"],
'ISO 8601 string as boolean' => ["2017-01-09T13:11:17", "boolean", "1"],
'ISO 8601 string as strict integer' => ["2017-01-09T13:11:17", "strict integer", "0"],
'ISO 8601 string as strict float' => ["2017-01-09T13:11:17", "strict float", "0.0"],
'ISO 8601 string as strict string' => ["2017-01-09T13:11:17", "strict string", "'2017-01-09T13:11:17'"],
'ISO 8601 string as strict datetime' => ["2017-01-09T13:11:17", "strict datetime", "'2017-01-09 13:11:17'"],
'ISO 8601 string as strict boolean' => ["2017-01-09T13:11:17", "strict boolean", "1"],
'Arbitrary date string as integer' => ["Today", "integer", "0"],
'Arbitrary date string as float' => ["Today", "float", "0.0"],
'Arbitrary date string as string' => ["Today", "string", "'Today'"],
'Arbitrary date string as datetime' => ["Today", "datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"],
'Arbitrary date string as boolean' => ["Today", "boolean", "1"],
'Arbitrary date string as strict integer' => ["Today", "strict integer", "0"],
'Arbitrary date string as strict float' => ["Today", "strict float", "0.0"],
'Arbitrary date string as strict string' => ["Today", "strict string", "'Today'"],
'Arbitrary date string as strict datetime' => ["Today", "strict datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"],
'Arbitrary date string as strict boolean' => ["Today", "strict boolean", "1"],
'DateTime as integer' => [$dateMutable, "integer", (string) $dateUTC->getTimestamp()],
'DateTime as float' => [$dateMutable, "float", $dateUTC->getTimestamp().".0"],
'DateTime as string' => [$dateMutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTime as datetime' => [$dateMutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTime as boolean' => [$dateMutable, "boolean", "1"],
'DateTime as strict integer' => [$dateMutable, "strict integer", (string) $dateUTC->getTimestamp()],
'DateTime as strict float' => [$dateMutable, "strict float", $dateUTC->getTimestamp().".0"],
'DateTime as strict string' => [$dateMutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTime as strict datetime' => [$dateMutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTime as strict boolean' => [$dateMutable, "strict boolean", "1"],
'DateTimeImmutable as integer' => [$dateImmutable, "integer", (string) $dateUTC->getTimestamp()],
'DateTimeImmutable as float' => [$dateImmutable, "float", $dateUTC->getTimestamp().".0"],
'DateTimeImmutable as string' => [$dateImmutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTimeImmutable as datetime' => [$dateImmutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTimeImmutable as boolean' => [$dateImmutable, "boolean", "1"],
'DateTimeImmutable as strict integer' => [$dateImmutable, "strict integer", (string) $dateUTC->getTimestamp()],
'DateTimeImmutable as strict float' => [$dateImmutable, "strict float", $dateUTC->getTimestamp().".0"],
'DateTimeImmutable as strict string' => [$dateImmutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTimeImmutable as strict datetime' => [$dateImmutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"],
'DateTimeImmutable as strict boolean' => [$dateImmutable, "strict boolean", "1"],
];
foreach ($tests as $index => list($value, $type, $exp)) {
$t = preg_replace("<^strict >", "", $type);
$exp = ($exp=="null") ? $exp : $this->decorateTypeSyntax($exp, $t);
yield $index => [$value, $type, $exp];
}
}
public function provideBinaryBindings() {
$dateMutable = new \DateTime("Noon Today", new \DateTimezone("America/Toronto"));
$dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto"));
$dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC"));
$tests = [
'Null as binary' => [null, "binary", "null"],
'Null as strict binary' => [null, "strict binary", "x''"],
'True as binary' => [true, "binary", "x'31'"],
'True as strict binary' => [true, "strict binary", "x'31'"],
'False as binary' => [false, "binary", "x''"],
'False as strict binary' => [false, "strict binary", "x''"],
'Integer as binary' => [2112, "binary", "x'32313132'"],
'Integer as strict binary' => [2112, "strict binary", "x'32313132'"],
'Integer zero as binary' => [0, "binary", "x'30'"],
'Integer zero as strict binary' => [0, "strict binary", "x'30'"],
'Float as binary' => [2112.5, "binary", "x'323131322e35'"],
'Float as strict binary' => [2112.5, "strict binary", "x'323131322e35'"],
'Float zero as binary' => [0.0, "binary", "x'30'"],
'Float zero as strict binary' => [0.0, "strict binary", "x'30'"],
'ASCII string as binary' => ["Random string", "binary", "x'52616e646f6d20737472696e67'"],
'ASCII string as strict binary' => ["Random string", "strict binary", "x'52616e646f6d20737472696e67'"],
'UTF-8 string as binary' => ["\u{e9}", "binary", "x'c3a9'"],
'UTF-8 string as strict binary' => ["\u{e9}", "strict binary", "x'c3a9'"],
'Binary string as integer' => [chr(233).chr(233), "integer", "0"],
'Binary string as float' => [chr(233).chr(233), "float", "0.0"],
'Binary string as string' => [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"],
'Binary string as binary' => [chr(233).chr(233), "binary", "x'e9e9'"],
'Binary string as datetime' => [chr(233).chr(233), "datetime", "null"],
'Binary string as boolean' => [chr(233).chr(233), "boolean", "1"],
'Binary string as strict integer' => [chr(233).chr(233), "strict integer", "0"],
'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"],
'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"],
'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"],
'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"],
'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"],
'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"],
'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"],
'Arbitrary date string as binary' => ["Today", "binary", "x'546f646179'"],
'Arbitrary date string as strict binary' => ["Today", "strict binary", "x'546f646179'"],
'DateTime as binary' => [$dateMutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"],
'DateTime as strict binary' => [$dateMutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"],
'DateTimeImmutable as binary' => [$dateImmutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"],
'DateTimeImmutable as strict binary' => [$dateImmutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"],
];
foreach ($tests as $index => list($value, $type, $exp)) {
$t = preg_replace("<^strict >", "", $type);
$exp = ($exp=="null") ? $exp : $this->decorateTypeSyntax($exp, $t);
yield $index => [$value, $type, $exp];
}
}
}

View file

@ -0,0 +1,136 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Test\DatabaseInformation;
use org\bovigo\vfs\vfsStream;
class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $dbInfo;
protected static $interface;
protected $drv;
protected $vfs;
protected $base;
protected $path;
public static function setUpBeforeClass() {
// establish a clean baseline
static::clearData();
static::$dbInfo = new DatabaseInformation(static::$implementation);
static::setConf();
static::$interface = (static::$dbInfo->interfaceConstructor)();
}
public function setUp() {
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
}
self::clearData();
self::setConf();
// construct a fresh driver for each test
$this->drv = new static::$dbInfo->driverClass;
$schemaId = (get_class($this->drv))::schemaID();
// set up a virtual filesystem for schema files
$this->vfs = vfsStream::setup("schemata", null, [$schemaId => []]);
$this->base = $this->vfs->url();
$this->path = $this->base."/$schemaId/";
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
}
public function tearDown() {
// deconstruct the driver
unset($this->drv);
unset($this->path, $this->base, $this->vfs);
self::clearData();
}
public static function tearDownAfterClass() {
if (static::$interface) {
// completely clear the database
(static::$dbInfo->razeFunction)(static::$interface);
}
static::$interface = null;
static::$dbInfo = null;
self::clearData();
}
public function testLoadMissingFile() {
$this->assertException("updateFileMissing", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadUnreadableFile() {
touch($this->path."0.sql");
chmod($this->path."0.sql", 0000);
$this->assertException("updateFileUnreadable", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadCorruptFile() {
file_put_contents($this->path."0.sql", "This is a corrupt file");
$this->assertException("updateFileError", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadIncompleteFile() {
file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);");
$this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadEmptyFile() {
file_put_contents($this->path."0.sql", "");
$this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadCorrectFile() {
file_put_contents($this->path."0.sql", static::$minimal1);
$this->drv->schemaUpdate(1, $this->base);
$this->assertEquals(1, $this->drv->schemaVersion());
}
public function testPerformPartialUpdate() {
file_put_contents($this->path."0.sql", static::$minimal1);
file_put_contents($this->path."1.sql", "UPDATE arsse_meta set value = '1' where key = 'schema_version'");
$this->assertException("updateFileIncomplete", "Db");
try {
$this->drv->schemaUpdate(2, $this->base);
} catch (Exception $e) {
$this->assertEquals(1, $this->drv->schemaVersion());
throw $e;
}
}
public function testPerformSequentialUpdate() {
file_put_contents($this->path."0.sql", static::$minimal1);
file_put_contents($this->path."1.sql", static::$minimal2);
$this->drv->schemaUpdate(2, $this->base);
$this->assertEquals(2, $this->drv->schemaVersion());
}
public function testPerformActualUpdate() {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertEquals(Database::SCHEMA_VERSION, $this->drv->schemaVersion());
}
public function testDeclineManualUpdate() {
// turn auto-updating off
Arsse::$conf->dbAutoUpdate = false;
$this->assertException("updateManual", "Db");
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
}
public function testDeclineDowngrade() {
$this->assertException("updateTooNew", "Db");
$this->drv->schemaUpdate(-1, $this->base);
}
}

View file

@ -0,0 +1,73 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\PostgreSQL\Driver;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Driver<extended> */
class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp() {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("PostgreSQL extension not loaded");
}
}
/** @dataProvider provideConnectionStrings */
public function testGenerateConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service, string $exp) {
self::setConf();
$timeout = (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0);
$postfix = "application_name='arsse' client_encoding='UTF8' connect_timeout='$timeout'";
$act = Driver::makeConnectionString($pdo, $user, $pass, $db, $host, $port, $service);
if ($act==$postfix) {
$this->assertSame($exp, "");
} else {
$test = substr($act, 0, strlen($act) - (strlen($postfix) + 1));
$check = substr($act, strlen($test) + 1);
$this->assertSame($postfix, $check);
$this->assertSame($exp, $test);
}
}
public function provideConnectionStrings() {
return [
[false, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse' password='secret' user='arsse'"],
[false, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse' password='p word' user='arsse'"],
[false, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse' password='p\\'word' user='arsse'"],
[false, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db' password='secret' user='arsse user'"],
[false, "arsse", "secret", "", "", 5432, "", "password='secret' user='arsse'"],
[false, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost' password='secret' user='arsse'"],
[false, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' password='secret' port='9999' user='arsse'"],
[false, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' password='secret' port='9999' user='arsse'"],
[false, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket' password='secret' user='arsse'"],
[false, "T'Pau of Vulcan", "", "", "", 5432, "", "user='T\\'Pau of Vulcan'"],
[false, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"],
[true, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db'"],
[true, "arsse", "secret", "", "", 5432, "", ""],
[true, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost'"],
[true, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' port='9999'"],
[true, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' port='9999'"],
[true, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket'"],
[true, "T'Pau of Vulcan", "", "", "", 5432, "", ""],
[true, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"],
];
}
public function testFailToConnect() {
// we cannnot distinguish between different connection failure modes
self::setConf([
'dbPostgreSQLPass' => (string) rand(),
]);
$this->assertException("connectionFailure", "Db");
new Driver;
}
}

View file

@ -0,0 +1,43 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @group coverageOptional
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query<extended>
*/
class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base {
protected static $implementation = "PostgreSQL";
protected function nextID(string $table): int {
return (int) static::$drv->query("SELECT coalesce(last_value, (select max(id) from $table)) + 1 from pg_sequences where sequencename = '{$table}_id_seq'")->getValue();
}
public function setUp() {
parent::setUp();
$seqList =
"select
replace(substring(column_default, 10), right(column_default, 12), '') as seq,
table_name as table,
column_name as col
from information_schema.columns
where table_schema = current_schema()
and table_name like 'arsse_%'
and column_default like 'nextval(%'
";
foreach (static::$drv->query($seqList) as $r) {
$num = (int) static::$drv->query("SELECT max({$r['col']}) from {$r['table']}")->getValue();
if (!$num) {
continue;
}
$num++;
static::$drv->exec("ALTER SEQUENCE {$r['seq']} RESTART WITH $num");
}
}
}

View file

@ -0,0 +1,58 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Driver<extended>
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch<extended> */
class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
protected static $implementation = "PostgreSQL";
protected $create = "CREATE TABLE arsse_test(id bigserial primary key)";
protected $lock = ["BEGIN", "LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"];
protected $setVersion = "UPDATE arsse_meta set value = '#' where key = 'schema_version'";
public function tearDown() {
try {
$this->drv->exec("ROLLBACK");
} catch (\Throwable $e) {
}
parent::tearDown();
}
public static function tearDownAfterClass() {
if (static::$interface) {
(static::$dbInfo->razeFunction)(static::$interface);
@pg_close(static::$interface);
static::$interface = null;
}
parent::tearDownAfterClass();
}
protected function exec($q): bool {
$q = (!is_array($q)) ? [$q] : $q;
foreach ($q as $query) {
set_error_handler(function($code, $msg) {
throw new \Exception($msg);
});
try {
pg_query(static::$interface, $query);
} finally {
restore_error_handler();
}
}
return true;
}
protected function query(string $q) {
if ($r = pg_query_params(static::$interface, $q, [])) {
return pg_fetch_result($r, 0, 0);
} else {
return;
}
}
}

View file

@ -0,0 +1,33 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
use JKingWeb\Arsse\Test\DatabaseInformation;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Result<extended>
*/
class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $implementation = "PostgreSQL";
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
protected function makeResult(string $q): array {
$set = pg_query(static::$interface, $q);
return [static::$interface, $set];
}
public static function tearDownAfterClass() {
if (static::$interface) {
(static::$dbInfo->razeFunction)(static::$interface);
@pg_close(static::$interface);
static::$interface = null;
}
parent::tearDownAfterClass();
}
}

View file

@ -0,0 +1,42 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Statement<extended>
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch<extended> */
class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
protected static $implementation = "PostgreSQL";
protected function makeStatement(string $q, array $types = []): array {
return [static::$interface, $q, $types];
}
protected function decorateTypeSyntax(string $value, string $type): string {
switch ($type) {
case "float":
return (substr($value, -2)==".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
default:
return $value;
}
}
public static function tearDownAfterClass() {
if (static::$interface) {
(static::$dbInfo->razeFunction)(static::$interface);
@pg_close(static::$interface);
static::$interface = null;
}
parent::tearDownAfterClass();
}
}

View file

@ -0,0 +1,16 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Driver<extended> */
class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate {
protected static $implementation = "PostgreSQL";
protected static $minimal1 = "CREATE TABLE arsse_meta(key text primary key, value text); INSERT INTO arsse_meta(key,value) values('schema_version','1');";
protected static $minimal2 = "UPDATE arsse_meta set value = '2' where key = 'schema_version';";
}

View file

@ -0,0 +1,73 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\PostgreSQL\PDODriver as Driver;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\PDODriver<extended> */
class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp() {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("PDO-PostgreSQL extension not loaded");
}
}
/** @dataProvider provideConnectionStrings */
public function testGenerateConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service, string $exp) {
self::setConf();
$timeout = (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0);
$postfix = "application_name='arsse' client_encoding='UTF8' connect_timeout='$timeout'";
$act = Driver::makeConnectionString($pdo, $user, $pass, $db, $host, $port, $service);
if ($act==$postfix) {
$this->assertSame($exp, "");
} else {
$test = substr($act, 0, strlen($act) - (strlen($postfix) + 1));
$check = substr($act, strlen($test) + 1);
$this->assertSame($postfix, $check);
$this->assertSame($exp, $test);
}
}
public function provideConnectionStrings() {
return [
[false, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse' password='secret' user='arsse'"],
[false, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse' password='p word' user='arsse'"],
[false, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse' password='p\\'word' user='arsse'"],
[false, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db' password='secret' user='arsse user'"],
[false, "arsse", "secret", "", "", 5432, "", "password='secret' user='arsse'"],
[false, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost' password='secret' user='arsse'"],
[false, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' password='secret' port='9999' user='arsse'"],
[false, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' password='secret' port='9999' user='arsse'"],
[false, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket' password='secret' user='arsse'"],
[false, "T'Pau of Vulcan", "", "", "", 5432, "", "user='T\\'Pau of Vulcan'"],
[false, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"],
[true, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse'"],
[true, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db'"],
[true, "arsse", "secret", "", "", 5432, "", ""],
[true, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost'"],
[true, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' port='9999'"],
[true, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' port='9999'"],
[true, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket'"],
[true, "T'Pau of Vulcan", "", "", "", 5432, "", ""],
[true, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"],
];
}
public function testFailToConnect() {
// PDO dies not distinguish between different connection failure modes
self::setConf([
'dbPostgreSQLPass' => (string) rand(),
]);
$this->assertException("connectionFailure", "Db");
new Driver;
}
}

View file

@ -0,0 +1,44 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
* @group optional
* @group coverageOptional
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query<extended>
*/
class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base {
protected static $implementation = "PDO PostgreSQL";
protected function nextID(string $table): int {
return (int) static::$drv->query("SELECT coalesce(last_value, (select max(id) from $table)) + 1 from pg_sequences where sequencename = '{$table}_id_seq'")->getValue();
}
public function setUp() {
parent::setUp();
$seqList =
"select
replace(substring(column_default, 10), right(column_default, 12), '') as seq,
table_name as table,
column_name as col
from information_schema.columns
where table_schema = current_schema()
and table_name like 'arsse_%'
and column_default like 'nextval(%'
";
foreach (static::$drv->query($seqList) as $r) {
$num = (int) static::$drv->query("SELECT max({$r['col']}) from {$r['table']}")->getValue();
if (!$num) {
continue;
}
$num++;
static::$drv->exec("ALTER SEQUENCE {$r['seq']} RESTART WITH $num");
}
}
}

View file

@ -0,0 +1,27 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\PDODriver<extended>
* @covers \JKingWeb\Arsse\Db\PDODriver
* @covers \JKingWeb\Arsse\Db\PDOError */
class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
protected static $implementation = "PDO PostgreSQL";
protected $create = "CREATE TABLE arsse_test(id bigserial primary key)";
protected $lock = ["BEGIN", "LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"];
protected $setVersion = "UPDATE arsse_meta set value = '#' where key = 'schema_version'";
public function tearDown() {
try {
$this->drv->exec("ROLLBACK");
} catch (\Throwable $e) {
}
parent::tearDown();
}
}

View file

@ -0,0 +1,24 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
use JKingWeb\Arsse\Test\DatabaseInformation;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PDOResult<extended>
*/
class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $implementation = "PDO PostgreSQL";
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
protected function makeResult(string $q): array {
$set = static::$interface->query($q);
return [static::$interface, $set];
}
}

View file

@ -0,0 +1,33 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\PDOStatement<extended>
* @covers \JKingWeb\Arsse\Db\PDOError */
class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
protected static $implementation = "PDO PostgreSQL";
protected function makeStatement(string $q, array $types = []): array {
return [static::$interface, $q, $types];
}
protected function decorateTypeSyntax(string $value, string $type): string {
switch ($type) {
case "float":
return (substr($value, -2)==".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
default:
return $value;
}
}
}

View file

@ -0,0 +1,17 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\PDODriver<extended>
* @covers \JKingWeb\Arsse\Db\PDOError */
class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate {
protected static $implementation = "PDO PostgreSQL";
protected static $minimal1 = "CREATE TABLE arsse_meta(key text primary key, value text); INSERT INTO arsse_meta(key,value) values('schema_version','1');";
protected static $minimal2 = "UPDATE arsse_meta set value = '2' where key = 'schema_version';";
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestArticle extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesArticle;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestCleanup extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesCleanup;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesFeed;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestFolder extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesFolder;
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestLabel extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesLabel;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestMeta extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesMeta;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestMiscellany extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesMiscellany;
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestSession extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesSession;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestSubscription extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesSubscription;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3;
use \JKingWeb\Arsse\Test\Database\SeriesUser;
}

View file

@ -24,7 +24,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("SQLite extension not loaded");
}
$this->clearData();
self::clearData();
// test files
$this->files = [
// cannot create files
@ -107,11 +107,11 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
chmod($path."Awal/arsse.db-wal", 0111);
chmod($path."Ashm/arsse.db-shm", 0111);
// set up configuration
$this->setConf(['dbSQLite3File' => ":memory:"]);
self::setConf();
}
public function tearDown() {
$this->clearData();
self::clearData();
}
public function testFailToCreateDatabase() {

View file

@ -0,0 +1,20 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
/**
* @group optional
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query<extended>
*/
class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base {
protected static $implementation = "SQLite 3";
protected function nextID(string $table): int {
return static::$drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue();
}
}

View file

@ -6,338 +6,42 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\SQLite3\Driver;
use JKingWeb\Arsse\Db\Result;
use JKingWeb\Arsse\Db\Statement;
/**
* @covers \JKingWeb\Arsse\Db\SQLite3\Driver<extended>
* @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */
class TestDriver extends \JKingWeb\Arsse\Test\AbstractTest {
protected $data;
protected $drv;
protected $ch;
class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
protected static $implementation = "SQLite 3";
protected $create = "CREATE TABLE arsse_test(id integer primary key)";
protected $lock = "BEGIN EXCLUSIVE TRANSACTION";
protected $setVersion = "PRAGMA user_version=#";
protected static $file;
public function setUp() {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("SQLite extension not loaded");
}
$this->clearData();
$this->setConf([
'dbDriver' => Driver::class,
'dbSQLite3Timeout' => 0,
'dbSQLite3File' => tempnam(sys_get_temp_dir(), 'ook'),
]);
$this->drv = new Driver();
$this->ch = new \SQLite3(Arsse::$conf->dbSQLite3File);
$this->ch->enableExceptions(true);
public static function setUpBeforeClass() {
// create a temporary database file rather than using a memory database
// some tests require one connection to block another, so a memory database is not suitable
static::$file = tempnam(sys_get_temp_dir(), 'ook');
static::$conf['dbSQLite3File'] = static::$file;
parent::setUpBeforeclass();
}
public function tearDown() {
unset($this->drv);
unset($this->ch);
if (isset(Arsse::$conf)) {
unlink(Arsse::$conf->dbSQLite3File);
}
$this->clearData();
public static function tearDownAfterClass() {
static::$interface->close();
static::$interface = null;
parent::tearDownAfterClass();
@unlink(static::$file);
static::$file = null;
}
public function testFetchDriverName() {
$class = Arsse::$conf->dbDriver;
$this->assertTrue(strlen($class::driverName()) > 0);
protected function exec($q): bool {
// SQLite's implementation coincidentally matches PDO's, but we reproduce it here for correctness' sake
$q = (!is_array($q)) ? [$q] : $q;
foreach ($q as $query) {
static::$interface->exec((string) $query);
}
return true;
}
public function testCheckCharacterSetAcceptability() {
$this->assertTrue($this->drv->charsetAcceptable());
}
public function testExecAValidStatement() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key)"));
}
public function testExecAnInvalidStatement() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->exec("And the meek shall inherit the earth...");
}
public function testExecMultipleStatements() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key); INSERT INTO test(id) values(2112)"));
$this->assertEquals(2112, $this->ch->querySingle("SELECT id from test"));
}
public function testExecTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->exec("CREATE TABLE test(id integer primary key)");
}
public function testExecConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values(null)");
}
public function testExecTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values('ook')");
}
public function testMakeAValidQuery() {
$this->assertInstanceOf(Result::class, $this->drv->query("SELECT 1"));
}
public function testMakeAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->query("Apollo was astonished; Dionysus thought me mad");
}
public function testQueryTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->query("CREATE TABLE test(id integer primary key)");
}
public function testQueryConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values(null)");
}
public function testQueryTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values('ook')");
}
public function testPrepareAValidQuery() {
$s = $this->drv->prepare("SELECT ?, ?", "int", "int");
$this->assertInstanceOf(Statement::class, $s);
}
public function testPrepareAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$s = $this->drv->prepare("This is an invalid query", "int", "int");
}
public function testCreateASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
}
public function testReleaseASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointRelease());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointRelease();
}
public function testUndoASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointUndo());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointUndo();
}
public function testManipulateSavepoints() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertEquals(5, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointUndo(3));
$this->assertFalse($this->drv->savepointRelease(4));
$this->assertEquals(6, $this->drv->savepointCreate());
$this->assertFalse($this->drv->savepointRelease(5));
$this->assertTrue($this->drv->savepointRelease(6));
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertException("savepointStale", "Db");
$this->drv->savepointRelease(2);
}
public function testManipulateSavepointsSomeMore() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertFalse($this->drv->savepointUndo(3));
$this->assertException("savepointStale", "Db");
$this->drv->savepointUndo(2);
}
public function testBeginATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
}
public function testCommitATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->ch->querySingle($select));
}
public function testRollbackATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
}
public function testBeginChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
}
public function testCommitChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2->commit();
$this->assertEquals(0, $this->ch->querySingle($select));
$tr1->commit();
$this->assertEquals(2, $this->ch->querySingle($select));
}
public function testCommitChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr1->commit();
$this->assertEquals(2, $this->ch->querySingle($select));
$tr2->commit();
}
public function testRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
}
public function testRollbackChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
}
public function testPartiallyRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->querySingle($select));
$tr1->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->ch->querySingle($select));
}
public function testFetchSchemaVersion() {
$this->assertSame(0, $this->drv->schemaVersion());
$this->drv->exec("PRAGMA user_version=1");
$this->assertSame(1, $this->drv->schemaVersion());
$this->drv->exec("PRAGMA user_version=2");
$this->assertSame(2, $this->drv->schemaVersion());
}
public function testLockTheDatabase() {
$this->drv->savepointCreate(true);
$this->assertException();
$this->ch->exec("CREATE TABLE test(id integer primary key)");
}
public function testUnlockTheDatabase() {
$this->drv->savepointCreate(true);
$this->drv->savepointRelease();
$this->drv->savepointCreate(true);
$this->drv->savepointUndo();
$this->assertSame(true, $this->ch->exec("CREATE TABLE test(id integer primary key)"));
protected function query(string $q) {
return static::$interface->querySingle($q);
}
}

View file

@ -0,0 +1,31 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
use JKingWeb\Arsse\Test\DatabaseInformation;
/**
* @covers \JKingWeb\Arsse\Db\SQLite3\Result<extended>
*/
class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $implementation = "SQLite 3";
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text) without rowid";
protected static $createTest = "CREATE TABLE arsse_test(id integer primary key)";
public static function tearDownAfterClass() {
static::$interface->close();
static::$interface = null;
parent::tearDownAfterClass();
}
protected function makeResult(string $q): array {
$set = static::$interface->query($q);
$rows = static::$interface->changes();
$id = static::$interface->lastInsertRowID();
return [$set, [$rows, $id]];
}
}

View file

@ -0,0 +1,28 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
/**
* @covers \JKingWeb\Arsse\Db\SQLite3\Statement<extended>
* @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */
class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
protected static $implementation = "SQLite 3";
public static function tearDownAfterClass() {
static::$interface->close();
static::$interface = null;
parent::tearDownAfterClass();
}
protected function makeStatement(string $q, array $types = []): array {
return [static::$interface, static::$interface->prepare($q), $types];
}
protected function decorateTypeSyntax(string $value, string $type): string {
return $value;
}
}

View file

@ -6,118 +6,17 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\SQLite3\Driver;
use org\bovigo\vfs\vfsStream;
/**
* @covers \JKingWeb\Arsse\Db\SQLite3\Driver<extended>
* @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */
class TestUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
protected $data;
protected $drv;
protected $vfs;
protected $base;
class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate {
protected static $implementation = "SQLite 3";
protected static $minimal1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1";
protected static $minimal2 = "pragma user_version=2";
const MINIMAL1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1";
const MINIMAL2 = "pragma user_version=2";
public function setUp(Conf $conf = null) {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("SQLite extension not loaded");
}
$this->clearData();
$this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]);
$conf = $conf ?? new Conf;
$conf->dbDriver = Driver::class;
$conf->dbSQLite3File = ":memory:";
Arsse::$conf = $conf;
$this->base = $this->vfs->url();
$this->path = $this->base."/SQLite3/";
$this->drv = new Driver();
}
public function tearDown() {
unset($this->drv);
unset($this->data);
unset($this->vfs);
$this->clearData();
}
public function testLoadMissingFile() {
$this->assertException("updateFileMissing", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadUnreadableFile() {
touch($this->path."0.sql");
chmod($this->path."0.sql", 0000);
$this->assertException("updateFileUnreadable", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadCorruptFile() {
file_put_contents($this->path."0.sql", "This is a corrupt file");
$this->assertException("updateFileError", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadIncompleteFile() {
file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);");
$this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadEmptyFile() {
file_put_contents($this->path."0.sql", "");
$this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1, $this->base);
}
public function testLoadCorrectFile() {
file_put_contents($this->path."0.sql", self::MINIMAL1);
$this->drv->schemaUpdate(1, $this->base);
$this->assertEquals(1, $this->drv->schemaVersion());
}
public function testPerformPartialUpdate() {
file_put_contents($this->path."0.sql", self::MINIMAL1);
file_put_contents($this->path."1.sql", " ");
$this->assertException("updateFileIncomplete", "Db");
try {
$this->drv->schemaUpdate(2, $this->base);
} catch (Exception $e) {
$this->assertEquals(1, $this->drv->schemaVersion());
throw $e;
}
}
public function testPerformSequentialUpdate() {
file_put_contents($this->path."0.sql", self::MINIMAL1);
file_put_contents($this->path."1.sql", self::MINIMAL2);
$this->drv->schemaUpdate(2, $this->base);
$this->assertEquals(2, $this->drv->schemaVersion());
}
public function testPerformActualUpdate() {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertEquals(Database::SCHEMA_VERSION, $this->drv->schemaVersion());
}
public function testDeclineManualUpdate() {
// turn auto-updating off
$conf = new Conf;
$conf->dbAutoUpdate = false;
$this->setUp($conf);
$this->assertException("updateManual", "Db");
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
}
public function testDeclineDowngrade() {
$this->assertException("updateTooNew", "Db");
$this->drv->schemaUpdate(-1, $this->base);
public static function tearDownAfterClass() {
static::$interface->close();
static::$interface = null;
parent::tearDownAfterClass();
}
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestArticle extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesArticle;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestCleanup extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesCleanup;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesFeed;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestFolder extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesFolder;
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestLabel extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesLabel;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestMeta extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesMeta;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestMiscellany extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesMiscellany;
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestSession extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesSession;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestSubscription extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesSubscription;
}

View file

@ -1,17 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO\Database;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query
*/
class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
use \JKingWeb\Arsse\Test\Database\Setup;
use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO;
use \JKingWeb\Arsse\Test\Database\SeriesUser;
}

View file

@ -25,7 +25,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("PDO-SQLite extension not loaded");
}
$this->clearData();
self::clearData();
// test files
$this->files = [
// cannot create files
@ -108,11 +108,11 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
chmod($path."Awal/arsse.db-wal", 0111);
chmod($path."Ashm/arsse.db-shm", 0111);
// set up configuration
$this->setConf(['dbSQLite3File' => ":memory:"]);
self::setConf();
}
public function tearDown() {
$this->clearData();
self::clearData();
}
public function testFailToCreateDatabase() {

View file

@ -0,0 +1,19 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO;
/**
* @covers \JKingWeb\Arsse\Database<extended>
* @covers \JKingWeb\Arsse\Misc\Query<extended>
*/
class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base {
protected static $implementation = "PDO SQLite 3";
protected function nextID(string $table): int {
return (int) static::$drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue();
}
}

View file

@ -6,339 +6,28 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\SQLite3\PDODriver;
use JKingWeb\Arsse\Db\Result;
use JKingWeb\Arsse\Db\Statement;
/**
* @covers \JKingWeb\Arsse\Db\SQLite3\PDODriver<extended>
* @covers \JKingWeb\Arsse\Db\PDODriver
* @covers \JKingWeb\Arsse\Db\PDOError */
class TestDriver extends \JKingWeb\Arsse\Test\AbstractTest {
protected $data;
protected $drv;
protected $ch;
class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
protected static $implementation = "PDO SQLite 3";
protected $create = "CREATE TABLE arsse_test(id integer primary key)";
protected $lock = "BEGIN EXCLUSIVE TRANSACTION";
protected $setVersion = "PRAGMA user_version=#";
protected static $file;
public function setUp() {
if (!PDODriver::requirementsMet()) {
$this->markTestSkipped("PDO-SQLite extension not loaded");
}
$this->clearData();
$this->setConf([
'dbDriver' => PDODriver::class,
'dbSQLite3Timeout' => 0,
'dbSQLite3File' => tempnam(sys_get_temp_dir(), 'ook'),
]);
$this->drv = new PDODriver();
$this->ch = new \PDO("sqlite:".Arsse::$conf->dbSQLite3File, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
public static function setUpBeforeClass() {
// create a temporary database file rather than using a memory database
// some tests require one connection to block another, so a memory database is not suitable
static::$file = tempnam(sys_get_temp_dir(), 'ook');
static::$conf['dbSQLite3File'] = static::$file;
parent::setUpBeforeclass();
}
public function tearDown() {
unset($this->drv);
unset($this->ch);
if (isset(Arsse::$conf)) {
unlink(Arsse::$conf->dbSQLite3File);
}
$this->clearData();
}
public function testFetchDriverName() {
$class = Arsse::$conf->dbDriver;
$this->assertTrue(strlen($class::driverName()) > 0);
}
public function testCheckCharacterSetAcceptability() {
$this->assertTrue($this->drv->charsetAcceptable());
}
public function testExecAValidStatement() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key)"));
}
public function testExecAnInvalidStatement() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->exec("And the meek shall inherit the earth...");
}
public function testExecMultipleStatements() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key); INSERT INTO test(id) values(2112)"));
$this->assertEquals(2112, $this->ch->query("SELECT id from test")->fetchColumn());
}
public function testExecTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->exec("CREATE TABLE test(id integer primary key)");
}
public function testExecConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values(null)");
}
public function testExecTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values('ook')");
}
public function testMakeAValidQuery() {
$this->assertInstanceOf(Result::class, $this->drv->query("SELECT 1"));
}
public function testMakeAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$this->drv->query("Apollo was astonished; Dionysus thought me mad");
}
public function testQueryTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->query("CREATE TABLE test(id integer primary key)");
}
public function testQueryConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values(null)");
}
public function testQueryTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values('ook')");
}
public function testPrepareAValidQuery() {
$s = $this->drv->prepare("SELECT ?, ?", "int", "int");
$this->assertInstanceOf(Statement::class, $s);
}
public function testPrepareAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db");
$s = $this->drv->prepare("This is an invalid query", "int", "int");
}
public function testCreateASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
}
public function testReleaseASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointRelease());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointRelease();
}
public function testUndoASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointUndo());
$this->assertException("savepointInvalid", "Db");
$this->drv->savepointUndo();
}
public function testManipulateSavepoints() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertEquals(5, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointUndo(3));
$this->assertFalse($this->drv->savepointRelease(4));
$this->assertEquals(6, $this->drv->savepointCreate());
$this->assertFalse($this->drv->savepointRelease(5));
$this->assertTrue($this->drv->savepointRelease(6));
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertException("savepointStale", "Db");
$this->drv->savepointRelease(2);
}
public function testManipulateSavepointsSomeMore() {
$this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate());
$this->assertEquals(4, $this->drv->savepointCreate());
$this->assertTrue($this->drv->savepointRelease(2));
$this->assertFalse($this->drv->savepointUndo(3));
$this->assertException("savepointStale", "Db");
$this->drv->savepointUndo(2);
}
public function testBeginATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
}
public function testCommitATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->ch->query($select)->fetchColumn());
}
public function testRollbackATransaction() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
}
public function testBeginChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
}
public function testCommitChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2->commit();
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr1->commit();
$this->assertEquals(2, $this->ch->query($select)->fetchColumn());
}
public function testCommitChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr1->commit();
$this->assertEquals(2, $this->ch->query($select)->fetchColumn());
$tr2->commit();
}
public function testRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
}
public function testRollbackChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr1->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2->rollback();
$this->assertEquals(0, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
}
public function testPartiallyRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)");
$tr1 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2 = $this->drv->begin();
$this->drv->query($insert);
$this->assertEquals(2, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr2->rollback();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(0, $this->ch->query($select)->fetchColumn());
$tr1->commit();
$this->assertEquals(1, $this->drv->query($select)->getValue());
$this->assertEquals(1, $this->ch->query($select)->fetchColumn());
}
public function testFetchSchemaVersion() {
$this->assertSame(0, $this->drv->schemaVersion());
$this->drv->exec("PRAGMA user_version=1");
$this->assertSame(1, $this->drv->schemaVersion());
$this->drv->exec("PRAGMA user_version=2");
$this->assertSame(2, $this->drv->schemaVersion());
}
public function testLockTheDatabase() {
$this->drv->savepointCreate(true);
$this->ch->exec("PRAGMA busy_timeout = 0");
$this->assertException();
$this->ch->exec("CREATE TABLE test(id integer primary key)");
}
public function testUnlockTheDatabase() {
$this->drv->savepointCreate(true);
$this->drv->savepointRelease();
$this->drv->savepointCreate(true);
$this->drv->savepointUndo();
$this->assertSame(0, $this->ch->exec("CREATE TABLE test(id integer primary key)"));
public static function tearDownAfterClass() {
parent::tearDownAfterClass();
@unlink(self::$file);
self::$file = null;
}
}

Some files were not shown because too many files have changed in this diff Show more