diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php
new file mode 100644
index 00000000..99dfe675
--- /dev/null
+++ b/lib/Db/PDODriver.php
@@ -0,0 +1,47 @@
+db->exec($query);
+ return true;
+ } catch (\PDOException $e) {
+ list($excClass, $excMsg, $excData) = $this->exceptionBuild();
+ throw new $excClass($excMsg, $excData);
+ }
+ }
+
+ public function query(string $query): Result {
+ try {
+ $r = $this->db->query($query);
+ } catch (\PDOException $e) {
+ 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]);
+ }
+
+ public function prepareArray(string $query, array $paramTypes): Statement {
+ try {
+ $s = $this->db->prepare($query);
+ } catch (\PDOException $e) {
+ list($excClass, $excMsg, $excData) = $this->exceptionBuild();
+ throw new $excClass($excMsg, $excData);
+ }
+ return new PDOStatement($this->db, $s, $paramTypes);
+ }
+}
\ No newline at end of file
diff --git a/lib/Db/PDOError.php b/lib/Db/PDOError.php
new file mode 100644
index 00000000..929fe1e1
--- /dev/null
+++ b/lib/Db/PDOError.php
@@ -0,0 +1,42 @@
+st->errorInfo();
+ } else {
+ $err = $this->db->errorInfo();
+ }
+ switch ($err[0]) {
+ case "23000":
+ return [ExceptionInput::class, "constraintViolation", $err[2]];
+ case "HY000":
+ // engine-specific errors
+ switch ($this->db->getAttribute(\PDO::ATTR_DRIVER_NAME)) {
+ case "sqlite":
+ switch ($err[1]) {
+ case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_BUSY:
+ return [ExceptionTimeout::class, 'general', $err[2]];
+ case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_MISMATCH:
+ return [ExceptionInput::class, 'engineTypeViolation', $err[2]];
+ default:
+ return [Exception::class, "engineErrorGeneral", $err[1]." - ".$err[2]];
+ }
+ default:
+ return [Exception::class, "engineErrorGeneral", $err[2]]; // @codeCoverageIgnore
+ }
+ default:
+ return [Exception::class, "engineErrorGeneral", $err[0].": ".$err[2]]; // @codeCoverageIgnore
+ }
+ }
+
+ public function getError(): string {
+ return (string) $this->db->errorInfo()[2];
+ }
+}
\ No newline at end of file
diff --git a/lib/Db/PDOResult.php b/lib/Db/PDOResult.php
new file mode 100644
index 00000000..32400e94
--- /dev/null
+++ b/lib/Db/PDOResult.php
@@ -0,0 +1,49 @@
+rows;
+ }
+
+ public function lastId() {
+ return $this->id;
+ }
+
+ // constructor/destructor
+
+ public function __construct(\PDOStatement $result, array $changes = [0,0]) {
+ $this->set = $result;
+ $this->rows = (int) $changes[0];
+ $this->id = (int) $changes[1];
+ }
+
+ public function __destruct() {
+ try {
+ $this->set->closeCursor();
+ } catch (\PDOException $e) { // @codeCoverageIgnore
+ }
+ unset($this->set);
+ }
+
+ // PHP iterator methods
+
+ public function valid() {
+ $this->cur = $this->set->fetch(\PDO::FETCH_ASSOC);
+ return ($this->cur !== false);
+ }
+}
diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php
new file mode 100644
index 00000000..a8d459ef
--- /dev/null
+++ b/lib/Db/PDOStatement.php
@@ -0,0 +1,85 @@
+ \PDO::PARAM_NULL,
+ "integer" => \PDO::PARAM_INT,
+ "float" => \PDO::PARAM_STR,
+ "date" => \PDO::PARAM_STR,
+ "time" => \PDO::PARAM_STR,
+ "datetime" => \PDO::PARAM_STR,
+ "binary" => \PDO::PARAM_LOB,
+ "string" => \PDO::PARAM_STR,
+ "boolean" => \PDO::PARAM_BOOL,
+ ];
+
+ protected $st;
+ protected $db;
+
+ public function __construct(\PDO $db, \PDOStatement $st, array $bindings = []) {
+ $this->db = $db;
+ $this->st = $st;
+ $this->rebindArray($bindings);
+ }
+
+ public function __destruct() {
+ unset($this->st);
+ }
+
+ public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
+ $this->st->closeCursor();
+ $this->bindValues($values);
+ try {
+ $this->st->execute();
+ } catch (\PDOException $e) {
+ 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]);
+ }
+
+ protected function bindValues(array $values, int $offset = 0): int {
+ $a = $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)) {
+ // if the parameter type is something other than the known values, this is an error
+ assert(array_key_exists($this->types[$a], self::BINDINGS), new Exception("paramTypeUnknown", $this->types[$a]));
+ // if the parameter type is null or the value is null (and the type is nullable), just bind null
+ if ($this->types[$a]=="null" || ($this->isNullable[$a] && is_null($value))) {
+ $this->st->bindValue($a+1, null, \PDO::PARAM_NULL);
+ } else {
+ // otherwise cast the value to the right type and bind the result
+ $type = self::BINDINGS[$this->types[$a]];
+ $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]);
+ // re-adjust for null casts
+ if ($value===null) {
+ $type = \PDO::PARAM_NULL;
+ }
+ // perform binding
+ $this->st->bindValue($a+1, $value, $type);
+ }
+ $a++;
+ } else {
+ throw new Exception("paramTypeMissing", $a+1);
+ }
+ }
+ return $a - $offset;
+ }
+}
diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php
new file mode 100644
index 00000000..a78dc24a
--- /dev/null
+++ b/lib/Db/SQLite3/PDODriver.php
@@ -0,0 +1,46 @@
+db = new \PDO("sqlite:".$file, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ }
+
+ 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.SQLite3PDO.Name");
+ }
+}
diff --git a/locale/en.php b/locale/en.php
index e2848473..a6d16987 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -16,6 +16,7 @@ return [
'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}',
'Driver.Db.SQLite3.Name' => 'SQLite 3',
+ 'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)',
'Driver.Service.Curl.Name' => 'HTTP (curl)',
'Driver.Service.Internal.Name' => 'Internal',
'Driver.User.Internal.Name' => 'Internal',
diff --git a/tests/cases/Db/SQLite3PDO/TestDbDriverCreationSQLite3PDO.php b/tests/cases/Db/SQLite3PDO/TestDbDriverCreationSQLite3PDO.php
new file mode 100644
index 00000000..436d4e2a
--- /dev/null
+++ b/tests/cases/Db/SQLite3PDO/TestDbDriverCreationSQLite3PDO.php
@@ -0,0 +1,195 @@
+
+ * @covers \JKingWeb\Arsse\Db\PDODriver
+ * @covers \JKingWeb\Arsse\Db\PDOError */
+class TestDbDriverCreationSQLite3PDO extends Test\AbstractTest {
+ protected $data;
+ protected $drv;
+ protected $ch;
+
+ public function setUp() {
+ if (!Driver::requirementsMet()) {
+ $this->markTestSkipped("PDO-SQLite extension not loaded");
+ }
+ $this->clearData();
+ // test files
+ $this->files = [
+ // cannot create files
+ 'Cmain' => [],
+ 'Cshm' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ ],
+ 'Cwal' => [
+ 'arsse.db' => "",
+ ],
+ // cannot write to files
+ 'Wmain' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Wwal' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Wshm' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ // cannot read from files
+ 'Rmain' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Rwal' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Rshm' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ // can neither read from or write to files
+ 'Amain' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Awal' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ 'Ashm' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ // non-filesystem errors
+ 'corrupt' => [
+ 'arsse.db' => "",
+ 'arsse.db-wal' => "",
+ 'arsse.db-shm' => "",
+ ],
+ ];
+ $vfs = vfsStream::setup("dbtest", 0777, $this->files);
+ $this->path = $path = $vfs->url()."/";
+ // set up access blocks
+ chmod($path."Cmain", 0555);
+ chmod($path."Cwal", 0555);
+ chmod($path."Cshm", 0555);
+ chmod($path."Rmain/arsse.db", 0333);
+ chmod($path."Rwal/arsse.db-wal", 0333);
+ chmod($path."Rshm/arsse.db-shm", 0333);
+ chmod($path."Wmain/arsse.db", 0555);
+ chmod($path."Wwal/arsse.db-wal", 0555);
+ chmod($path."Wshm/arsse.db-shm", 0555);
+ chmod($path."Amain/arsse.db", 0111);
+ chmod($path."Awal/arsse.db-wal", 0111);
+ chmod($path."Ashm/arsse.db-shm", 0111);
+ // set up configuration
+ Arsse::$conf = new Conf();
+ Arsse::$conf->dbSQLite3File = ":memory:";
+ }
+
+ public function tearDown() {
+ $this->clearData();
+ }
+
+ public function testFailToCreateDatabase() {
+ Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db";
+ $this->assertException("fileUncreatable", "Db");
+ new Driver;
+ }
+
+ public function testFailToCreateJournal() {
+ Arsse::$conf->dbSQLite3File = $this->path."Cwal/arsse.db";
+ $this->assertException("fileUncreatable", "Db");
+ new Driver;
+ }
+
+ public function testFailToCreateSharedMmeory() {
+ Arsse::$conf->dbSQLite3File = $this->path."Cshm/arsse.db";
+ $this->assertException("fileUncreatable", "Db");
+ new Driver;
+ }
+
+ public function testFailToReadDatabase() {
+ Arsse::$conf->dbSQLite3File = $this->path."Rmain/arsse.db";
+ $this->assertException("fileUnreadable", "Db");
+ new Driver;
+ }
+
+ public function testFailToReadJournal() {
+ Arsse::$conf->dbSQLite3File = $this->path."Rwal/arsse.db";
+ $this->assertException("fileUnreadable", "Db");
+ new Driver;
+ }
+
+ public function testFailToReadSharedMmeory() {
+ Arsse::$conf->dbSQLite3File = $this->path."Rshm/arsse.db";
+ $this->assertException("fileUnreadable", "Db");
+ new Driver;
+ }
+
+ public function testFailToWriteToDatabase() {
+ Arsse::$conf->dbSQLite3File = $this->path."Wmain/arsse.db";
+ $this->assertException("fileUnwritable", "Db");
+ new Driver;
+ }
+
+ public function testFailToWriteToJournal() {
+ Arsse::$conf->dbSQLite3File = $this->path."Wwal/arsse.db";
+ $this->assertException("fileUnwritable", "Db");
+ new Driver;
+ }
+
+ public function testFailToWriteToSharedMmeory() {
+ Arsse::$conf->dbSQLite3File = $this->path."Wshm/arsse.db";
+ $this->assertException("fileUnwritable", "Db");
+ new Driver;
+ }
+
+ public function testFailToAccessDatabase() {
+ Arsse::$conf->dbSQLite3File = $this->path."Amain/arsse.db";
+ $this->assertException("fileUnusable", "Db");
+ new Driver;
+ }
+
+ public function testFailToAccessJournal() {
+ Arsse::$conf->dbSQLite3File = $this->path."Awal/arsse.db";
+ $this->assertException("fileUnusable", "Db");
+ new Driver;
+ }
+
+ public function testFailToAccessSharedMmeory() {
+ Arsse::$conf->dbSQLite3File = $this->path."Ashm/arsse.db";
+ $this->assertException("fileUnusable", "Db");
+ new Driver;
+ }
+
+ public function testAssumeDatabaseCorruption() {
+ Arsse::$conf->dbSQLite3File = $this->path."corrupt/arsse.db";
+ $this->assertException("fileCorrupt", "Db");
+ new Driver;
+ }
+}
diff --git a/tests/cases/Db/SQLite3PDO/TestDbDriverSQLite3PDO.php b/tests/cases/Db/SQLite3PDO/TestDbDriverSQLite3PDO.php
new file mode 100644
index 00000000..bb96112d
--- /dev/null
+++ b/tests/cases/Db/SQLite3PDO/TestDbDriverSQLite3PDO.php
@@ -0,0 +1,337 @@
+
+ * @covers \JKingWeb\Arsse\Db\PDODriver
+ * @covers \JKingWeb\Arsse\Db\PDOError */
+class TestDbDriverSQLite3PDO extends Test\AbstractTest {
+ protected $data;
+ protected $drv;
+ protected $ch;
+
+ public function setUp() {
+ if (!Db\SQLite3\PDODriver::requirementsMet()) {
+ $this->markTestSkipped("PDO-SQLite extension not loaded");
+ }
+ $this->clearData();
+ $conf = new Conf();
+ Arsse::$conf = $conf;
+ $conf->dbDriver = Db\SQLite3\PDODriver::class;
+ $conf->dbSQLite3Timeout = 0;
+ $conf->dbSQLite3File = tempnam(sys_get_temp_dir(), 'ook');
+ $this->drv = new Db\SQLite3\PDODriver();
+ $this->ch = new \PDO("sqlite:".Arsse::$conf->dbSQLite3File, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ }
+
+ 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(Db\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(Db\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)"));
+ }
+}
diff --git a/tests/cases/Db/SQLite3PDO/TestDbResultSQLite3PDO.php b/tests/cases/Db/SQLite3PDO/TestDbResultSQLite3PDO.php
new file mode 100644
index 00000000..fbc745f4
--- /dev/null
+++ b/tests/cases/Db/SQLite3PDO/TestDbResultSQLite3PDO.php
@@ -0,0 +1,104 @@
+ */
+class TestDbResultSQLite3PDO extends Test\AbstractTest {
+ protected $c;
+
+ public function setUp() {
+ $this->clearData();
+ if (!Db\SQLite3\PDODriver::requirementsMet()) {
+ $this->markTestSkipped("PDO-SQLite extension not loaded");
+ }
+ $c = new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ $this->c = $c;
+ }
+
+ public function tearDown() {
+ unset($this->c);
+ $this->clearData();
+ }
+
+ public function testConstructResult() {
+ $set = $this->c->query("SELECT 1");
+ $this->assertInstanceOf(Db\Result::class, new Db\PDOResult($set));
+ }
+
+ public function testGetChangeCountAndLastInsertId() {
+ $this->c->query("CREATE TABLE test(col)");
+ $set = $this->c->query("INSERT INTO test(col) values(1)");
+ $rows = $set->rowCount();
+ $id = $this->c->lastInsertID();
+ $r = new Db\PDOResult($set, [$rows,$id]);
+ $this->assertSame((int) $rows, $r->changes());
+ $this->assertSame((int) $id, $r->lastId());
+ }
+
+ public function testIterateOverResults() {
+ $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col");
+ $rows = [];
+ foreach (new Db\PDOResult($set) as $index => $row) {
+ $rows[$index] = $row['col'];
+ }
+ $this->assertSame([0 => "1", 1 => "2", 2 => "3"], $rows);
+ }
+
+ public function testIterateOverResultsTwice() {
+ $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col");
+ $rows = [];
+ $test = new Db\PDOResult($set);
+ foreach ($test as $row) {
+ $rows[] = $row['col'];
+ }
+ $this->assertSame(["1","2","3"], $rows);
+ $this->assertException("resultReused", "Db");
+ foreach ($test as $row) {
+ $rows[] = $row['col'];
+ }
+ }
+
+ public function testGetSingleValues() {
+ $set = $this->c->query("SELECT 1867 as year union select 1970 as year union select 2112 as year");
+ $test = new Db\PDOResult($set);
+ $this->assertEquals(1867, $test->getValue());
+ $this->assertEquals(1970, $test->getValue());
+ $this->assertEquals(2112, $test->getValue());
+ $this->assertSame(null, $test->getValue());
+ }
+
+ public function testGetFirstValuesOnly() {
+ $set = $this->c->query("SELECT 1867 as year, 19 as century union select 1970 as year, 20 as century union select 2112 as year, 22 as century");
+ $test = new Db\PDOResult($set);
+ $this->assertEquals(1867, $test->getValue());
+ $this->assertEquals(1970, $test->getValue());
+ $this->assertEquals(2112, $test->getValue());
+ $this->assertSame(null, $test->getValue());
+ }
+
+ public function testGetRows() {
+ $set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track");
+ $rows = [
+ ['album' => '2112', 'track' => '2112'],
+ ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
+ ];
+ $test = new Db\PDOResult($set);
+ $this->assertEquals($rows[0], $test->getRow());
+ $this->assertEquals($rows[1], $test->getRow());
+ $this->assertSame(null, $test->getRow());
+ }
+
+ public function testGetAllRows() {
+ $set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track");
+ $rows = [
+ ['album' => '2112', 'track' => '2112'],
+ ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
+ ];
+ $test = new Db\PDOResult($set);
+ $this->assertEquals($rows, $test->getAll());
+ }
+}
diff --git a/tests/cases/Db/SQLite3PDO/TestDbStatementSQLite3PDO.php b/tests/cases/Db/SQLite3PDO/TestDbStatementSQLite3PDO.php
new file mode 100644
index 00000000..76ca24a1
--- /dev/null
+++ b/tests/cases/Db/SQLite3PDO/TestDbStatementSQLite3PDO.php
@@ -0,0 +1,105 @@
+
+ * @covers \JKingWeb\Arsse\Db\PDOError */
+class TestDbStatementSQLite3PDO extends Test\AbstractTest {
+
+ protected $c;
+ protected static $imp = Db\PDOStatement::class;
+
+ public function setUp() {
+ $this->clearData();
+ if (!Db\SQLite3\PDODriver::requirementsMet()) {
+ $this->markTestSkipped("PDO-SQLite extension not loaded");
+ }
+ $c = new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ $this->c = $c;
+ }
+
+ public function tearDown() {
+ unset($this->c);
+ $this->clearData();
+ }
+
+ protected function checkBinding($input, array $expectations, bool $strict = false) {
+ $nativeStatement = $this->c->prepare("SELECT ? as value");
+ $s = new self::$imp($this->c, $nativeStatement);
+ $types = array_unique(Statement::TYPES);
+ foreach ($types as $type) {
+ $s->rebindArray([$strict ? "strict $type" : $type]);
+ $val = $s->runArray([$input])->getRow()['value'];
+ $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison.");
+ $s->rebind(...[$strict ? "strict $type" : $type]);
+ $val = $s->run(...[$input])->getRow()['value'];
+ $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison.");
+ }
+ }
+
+ public function testConstructStatement() {
+ $nativeStatement = $this->c->prepare("SELECT ? as value");
+ $this->assertInstanceOf(Statement::class, new Db\PDOStatement($this->c, $nativeStatement));
+ }
+
+ public function testBindMissingValue() {
+ $nativeStatement = $this->c->prepare("SELECT ? as value");
+ $s = new self::$imp($this->c, $nativeStatement);
+ $val = $s->runArray()->getRow()['value'];
+ $this->assertSame(null, $val);
+ }
+
+ public function testBindMultipleValues() {
+ $exp = [
+ 'one' => "1",
+ 'two' => "2",
+ ];
+ $nativeStatement = $this->c->prepare("SELECT ? as one, ? as two");
+ $s = new self::$imp($this->c, $nativeStatement, ["int", "int"]);
+ $val = $s->runArray([1,2])->getRow();
+ $this->assertSame($exp, $val);
+ }
+
+ public function testBindRecursively() {
+ $exp = [
+ 'one' => "1",
+ 'two' => "2",
+ 'three' => "3",
+ 'four' => "4",
+ ];
+ $nativeStatement = $this->c->prepare("SELECT ? as one, ? as two, ? as three, ? as four");
+ $s = new self::$imp($this->c, $nativeStatement, ["int", ["int", "int"], "int"]);
+ $val = $s->runArray([1, [2, 3], 4])->getRow();
+ $this->assertSame($exp, $val);
+ }
+
+ public function testBindWithoutType() {
+ $nativeStatement = $this->c->prepare("SELECT ? as value");
+ $this->assertException("paramTypeMissing", "Db");
+ $s = new self::$imp($this->c, $nativeStatement, []);
+ $s->runArray([1]);
+ }
+
+ public function testViolateConstraint() {
+ $this->c->exec("CREATE TABLE test(id integer not null)");
+ $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)");
+ $s = new self::$imp($this->c, $nativeStatement, ["int"]);
+ $this->assertException("constraintViolation", "Db", "ExceptionInput");
+ $s->runArray([null]);
+ }
+
+ public function testMismatchTypes() {
+ $this->c->exec("CREATE TABLE test(id integer primary key)");
+ $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)");
+ $s = new self::$imp($this->c, $nativeStatement, ["str"]);
+ $this->assertException("typeViolation", "Db", "ExceptionInput");
+ $s->runArray(['ook']);
+ }
+}
diff --git a/tests/cases/Db/SQLite3PDO/TestDbUpdateSQLite3PDO.php b/tests/cases/Db/SQLite3PDO/TestDbUpdateSQLite3PDO.php
new file mode 100644
index 00000000..b75ba7d0
--- /dev/null
+++ b/tests/cases/Db/SQLite3PDO/TestDbUpdateSQLite3PDO.php
@@ -0,0 +1,120 @@
+
+ * @covers \JKingWeb\Arsse\Db\PDOError */
+class TestDbUpdateSQLite3PDO extends Test\AbstractTest {
+ protected $data;
+ protected $drv;
+ protected $vfs;
+ protected $base;
+
+ 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 (!Db\SQLite3\PDODriver::requirementsMet()) {
+ $this->markTestSkipped("PDO-SQLite extension not loaded");
+ }
+ $this->clearData();
+ $this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]);
+ if (!$conf) {
+ $conf = new Conf();
+ }
+ $conf->dbDriver = Db\SQLite3\PDODriver::class;
+ $conf->dbSQLite3File = ":memory:";
+ Arsse::$conf = $conf;
+ $this->base = $this->vfs->url();
+ $this->path = $this->base."/SQLite3/";
+ $this->drv = new Db\SQLite3\PDODriver();
+ }
+
+ 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);
+ }
+}
diff --git a/tests/phpunit.xml b/tests/phpunit.xml
index 76d41e97..c15db129 100644
--- a/tests/phpunit.xml
+++ b/tests/phpunit.xml
@@ -51,8 +51,14 @@
cases/Db/SQLite3/TestDbDriverCreationSQLite3.php
cases/Db/SQLite3/TestDbDriverSQLite3.php
cases/Db/SQLite3/TestDbUpdateSQLite3.php
+
+ cases/Db/SQLite3PDO/TestDbResultSQLite3PDO.php
+ cases/Db/SQLite3PDO/TestDbStatementSQLite3PDO.php
+ cases/Db/SQLite3PDO/TestDbDriverCreationSQLite3PDO.php
+ cases/Db/SQLite3PDO/TestDbDriverSQLite3PDO.php
+ cases/Db/SQLite3PDO/TestDbUpdateSQLite3PDO.php
-
+
cases/Db/SQLite3/Database/TestDatabaseMiscellanySQLite3.php
cases/Db/SQLite3/Database/TestDatabaseMetaSQLite3.php
cases/Db/SQLite3/Database/TestDatabaseUserSQLite3.php