From 2bebdd44cf83ac6900f20d74473627587c6ef91b Mon Sep 17 00:00:00 2001
From: "J. King" <jking@jkingweb.ca>
Date: Thu, 13 Dec 2018 19:47:51 -0500
Subject: [PATCH] Implementation of native PostgreSQL interface

Changes to the Database class were required to avoid outputting booleans
---
 lib/Database.php                              |   8 +-
 lib/Db/PostgreSQL/Dispatch.php                |  42 ++++++++
 lib/Db/PostgreSQL/Driver.php                  |  51 ++++++---
 lib/Db/PostgreSQL/PDOStatement.php            |  28 +----
 lib/Db/PostgreSQL/Result.php                  |  48 +++++++++
 lib/Db/PostgreSQL/Statement.php               |  77 ++++++++++++++
 locale/en.php                                 |   2 +-
 tests/cases/Db/PostgreSQL/TestCreation.php    |  73 +++++++++++++
 tests/cases/Db/PostgreSQL/TestDatabase.php    |  43 ++++++++
 tests/cases/Db/PostgreSQL/TestDriver.php      |  57 ++++++++++
 tests/cases/Db/PostgreSQL/TestResult.php      |  33 ++++++
 tests/cases/Db/PostgreSQL/TestStatement.php   |  41 +++++++
 tests/cases/Db/PostgreSQL/TestUpdate.php      |  16 +++
 tests/cases/Db/PostgreSQLPDO/TestCreation.php |   6 ++
 tests/cases/Db/PostgreSQLPDO/TestDatabase.php |   1 +
 tests/cases/Db/SQLite3/TestDatabase.php       |   1 +
 tests/lib/DatabaseInformation.php             | 100 ++++++++++++------
 17 files changed, 547 insertions(+), 80 deletions(-)
 create mode 100644 lib/Db/PostgreSQL/Dispatch.php
 create mode 100644 lib/Db/PostgreSQL/Result.php
 create mode 100644 lib/Db/PostgreSQL/Statement.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestCreation.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestDatabase.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestDriver.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestResult.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestStatement.php
 create mode 100644 tests/cases/Db/PostgreSQL/TestUpdate.php

diff --git a/lib/Database.php b/lib/Database.php
index 18da7775..ae175d7d 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -384,9 +384,9 @@ class Database {
                 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 = userid 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",
@@ -418,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;
diff --git a/lib/Db/PostgreSQL/Dispatch.php b/lib/Db/PostgreSQL/Dispatch.php
new file mode 100644
index 00000000..c6cb198a
--- /dev/null
+++ b/lib/Db/PostgreSQL/Dispatch.php
@@ -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
+        }
+    }
+}
diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php
index 37603248..e681f72c 100644
--- a/lib/Db/PostgreSQL/Driver.php
+++ b/lib/Db/PostgreSQL/Driver.php
@@ -13,6 +13,9 @@ 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) {
@@ -156,46 +159,60 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
     }
 
     public function __destruct() {
+        if (isset($this->db)) {
+            pg_close($this->db);
+            unset($this->db);
+        }
     }
 
-    /** @codeCoverageIgnore */
     public static function driverName(): string {
         return Arsse::$lang->msg("Driver.Db.PostgreSQL.Name");
     }
 
-    /** @codeCoverageIgnore */
     public static function requirementsMet(): bool {
-        // stub: native interface is not yet supported
-        return false;
+        return \extension_loaded("pgsql");
     }
 
-    /** @codeCoverageIgnore */
     protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) {
-        // stub: native interface is not yet supported
-        throw new \Exception;
+        $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();
+        }
     }
 
-    /** @codeCoverageIgnore */
     protected function getError(): string {
-        // stub: native interface is not yet supported
+        // stub
         return "";
     }
 
-    /** @codeCoverageIgnore */
     public function exec(string $query): bool {
-        // stub: native interface is not yet supported
+        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;
     }
 
-    /** @codeCoverageIgnore */
     public function query(string $query): \JKingWeb\Arsse\Db\Result {
-        // stub: native interface is not yet supported
-        return new ResultEmpty;
+        $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);
+        }
     }
 
-    /** @codeCoverageIgnore */
     public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
-        // stub: native interface is not yet supported
-        return new Statement($this->db, $s, $paramTypes);
+        return new Statement($this->db, $query, $paramTypes);
     }
 }
diff --git a/lib/Db/PostgreSQL/PDOStatement.php b/lib/Db/PostgreSQL/PDOStatement.php
index 16582609..534efbcb 100644
--- a/lib/Db/PostgreSQL/PDOStatement.php
+++ b/lib/Db/PostgreSQL/PDOStatement.php
@@ -6,18 +6,9 @@
 declare(strict_types=1);
 namespace JKingWeb\Arsse\Db\PostgreSQL;
 
-class PDOStatement extends \JKingWeb\Arsse\Db\AbstractStatement {
+class PDOStatement extends Statement {
     use \JKingWeb\Arsse\Db\PDOError;
 
-    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 $st;
     protected $qOriginal;
@@ -25,7 +16,7 @@ class PDOStatement extends \JKingWeb\Arsse\Db\AbstractStatement {
     protected $bindings;
 
     public function __construct(\PDO $db, string $query, array $bindings = []) {
-        $this->db = $db; // both db and st are the same object due to the logic of the PDOError handler
+        $this->db = $db;
         $this->qOriginal = $query;
         $this->retypeArray($bindings);
     }
@@ -53,19 +44,6 @@ class PDOStatement extends \JKingWeb\Arsse\Db\AbstractStatement {
         return true;
     }
 
-    public 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;
-    }
-
     public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result {
         return $this->st->runArray($values);
     }
@@ -73,6 +51,6 @@ class PDOStatement extends \JKingWeb\Arsse\Db\AbstractStatement {
     /** @codeCoverageIgnore */
     protected function bindValue($value, string $type, int $position): bool {
         // stub required by abstract parent, but never used
-        return $value;
+        return true;
     }
 }
diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php
new file mode 100644
index 00000000..3b6cf9c6
--- /dev/null
+++ b/lib/Db/PostgreSQL/Result.php
@@ -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);
+    }
+}
diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php
new file mode 100644
index 00000000..bccd0fb0
--- /dev/null
+++ b/lib/Db/PostgreSQL/Statement.php
@@ -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;
+    }
+}
diff --git a/locale/en.php b/locale/en.php
index 3e63ac69..dc5381f1 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -159,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}',
diff --git a/tests/cases/Db/PostgreSQL/TestCreation.php b/tests/cases/Db/PostgreSQL/TestCreation.php
new file mode 100644
index 00000000..182d3a34
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestCreation.php
@@ -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;
+    }
+}
diff --git a/tests/cases/Db/PostgreSQL/TestDatabase.php b/tests/cases/Db/PostgreSQL/TestDatabase.php
new file mode 100644
index 00000000..efc19a60
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestDatabase.php
@@ -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");
+        }
+    }
+}
diff --git a/tests/cases/Db/PostgreSQL/TestDriver.php b/tests/cases/Db/PostgreSQL/TestDriver.php
new file mode 100644
index 00000000..86decea5
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestDriver.php
@@ -0,0 +1,57 @@
+<?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 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;
+        }
+    }
+}
diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php
new file mode 100644
index 00000000..08fff06b
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestResult.php
@@ -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();
+    }
+}
diff --git a/tests/cases/Db/PostgreSQL/TestStatement.php b/tests/cases/Db/PostgreSQL/TestStatement.php
new file mode 100644
index 00000000..de350dbc
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestStatement.php
@@ -0,0 +1,41 @@
+<?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> */
+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();
+    }
+}
diff --git a/tests/cases/Db/PostgreSQL/TestUpdate.php b/tests/cases/Db/PostgreSQL/TestUpdate.php
new file mode 100644
index 00000000..cbdcb0bd
--- /dev/null
+++ b/tests/cases/Db/PostgreSQL/TestUpdate.php
@@ -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';";
+}
diff --git a/tests/cases/Db/PostgreSQLPDO/TestCreation.php b/tests/cases/Db/PostgreSQLPDO/TestCreation.php
index 3561ff46..83d95e89 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestCreation.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestCreation.php
@@ -13,6 +13,12 @@ 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();
diff --git a/tests/cases/Db/PostgreSQLPDO/TestDatabase.php b/tests/cases/Db/PostgreSQLPDO/TestDatabase.php
index 42d5f9f7..6ce5de70 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestDatabase.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestDatabase.php
@@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
 
 /**
  * @group slow
+ * @group optional
  * @group coverageOptional
  * @covers \JKingWeb\Arsse\Database<extended>
  * @covers \JKingWeb\Arsse\Misc\Query<extended>
diff --git a/tests/cases/Db/SQLite3/TestDatabase.php b/tests/cases/Db/SQLite3/TestDatabase.php
index 35448d49..c65027c3 100644
--- a/tests/cases/Db/SQLite3/TestDatabase.php
+++ b/tests/cases/Db/SQLite3/TestDatabase.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
 namespace JKingWeb\Arsse\TestCase\Db\SQLite3;
 
 /**
+ * @group optional
  * @covers \JKingWeb\Arsse\Database<extended>
  * @covers \JKingWeb\Arsse\Misc\Query<extended>
  */
diff --git a/tests/lib/DatabaseInformation.php b/tests/lib/DatabaseInformation.php
index a550847b..ea70afc0 100644
--- a/tests/lib/DatabaseInformation.php
+++ b/tests/lib/DatabaseInformation.php
@@ -116,7 +116,50 @@ class DatabaseInformation {
             } elseif ($db instanceof \PDO) {
                 return $db->query($listObjects)->fetchAll(\PDO::FETCH_ASSOC);
             } else {
-                throw \Exception("Native PostgreSQL interface not implemented");
+                $r = @pg_query($db, $listObjects);
+                $out = $r ? pg_fetch_all($r) : false;
+                return $out ? $out : [];
+            }
+        };
+        $pgExecFunction = function($db, $q) {
+            if ($db instanceof Driver) {
+                $db->exec($q);
+            } elseif ($db instanceof \PDO) {
+                $db->exec($q);
+            } else {
+                pg_query($db, $q);
+            }
+        };
+        $pgTruncateFunction = function($db, array $afterStatements = []) use ($pgObjectList, $pgExecFunction) {
+            // rollback any pending transaction
+            try {
+                @$pgExecFunction($db, "ROLLBACK");
+            } catch (\Throwable $e) {
+            }
+            foreach ($pgObjectList($db) as $obj) {
+                if ($obj['type'] != "TABLE") {
+                    continue;
+                } elseif ($obj['name'] == "arsse_meta") {
+                    $pgExecFunction($db, "DELETE FROM {$obj['name']} where key <> 'schema_version'");
+                } else {
+                    $pgExecFunction($db, "TRUNCATE TABLE {$obj['name']} restart identity cascade");
+                }
+            }
+            foreach ($afterStatements as $st) {
+                $pgExecFunction($db, $st);
+            }
+        };
+        $pgRazeFunction = function($db, array $afterStatements = []) use ($pgObjectList, $pgExecFunction) {
+            // rollback any pending transaction
+            try {
+                $pgExecFunction($db, "ROLLBACK");
+            } catch (\Throwable $e) {
+            }
+            foreach ($pgObjectList($db) as $obj) {
+                $pgExecFunction($db, "DROP {$obj['type']} IF EXISTS {$obj['name']} cascade");
+            }
+            foreach ($afterStatements as $st) {
+                $pgExecFunction($db, $st);
             }
         };
         return [
@@ -158,6 +201,27 @@ class DatabaseInformation {
                 'truncateFunction' => $sqlite3TruncateFunction,
                 'razeFunction' => $sqlite3RazeFunction,
             ],
+            'PostgreSQL' => [
+                'pdo' => false,
+                'backend' => "PostgreSQL",
+                'statementClass' => \JKingWeb\Arsse\Db\PostgreSQL\Statement::class,
+                'resultClass' => \JKingWeb\Arsse\Db\PostgreSQL\Result::class,
+                'driverClass' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class,
+                'stringOutput' => true,
+                'interfaceConstructor' => function() {
+                    $connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(false, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, "");
+                    if ($d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) {
+                        foreach (\JKingWeb\Arsse\Db\PostgreSQL\Driver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) {
+                            pg_query($d, $q);
+                        }
+                        return $d;
+                    } else {
+                        return;
+                    }
+                },
+                'truncateFunction' => $pgTruncateFunction,
+                'razeFunction' => $pgRazeFunction,
+            ],
             'PDO PostgreSQL' => [
                 'pdo' => true,
                 'backend' => "PostgreSQL",
@@ -177,38 +241,8 @@ class DatabaseInformation {
                     }
                     return $d;
                 },
-                'truncateFunction' => function($db, array $afterStatements = []) use ($pgObjectList) {
-                    // rollback any pending transaction
-                    try {
-                        $db->exec("ROLLBACK");
-                    } catch (\Throwable $e) {
-                    }
-                    foreach ($pgObjectList($db) as $obj) {
-                        if ($obj['type'] != "TABLE") {
-                            continue;
-                        } elseif ($obj['name'] == "arsse_meta") {
-                            $db->exec("DELETE FROM {$obj['name']} where key <> 'schema_version'");
-                        } else {
-                            $db->exec("TRUNCATE TABLE {$obj['name']} restart identity cascade");
-                        }
-                    }
-                    foreach ($afterStatements as $st) {
-                        $db->exec($st);
-                    }
-                },
-                'razeFunction' => function($db, array $afterStatements = []) use ($pgObjectList) {
-                    // rollback any pending transaction
-                    try {
-                        $db->exec("ROLLBACK");
-                    } catch (\Throwable $e) {
-                    }
-                    foreach ($pgObjectList($db) as $obj) {
-                        $db->exec("DROP {$obj['type']} IF EXISTS {$obj['name']} cascade");
-                    }
-                    foreach ($afterStatements as $st) {
-                        $db->exec($st);
-                    }
-                },
+                'truncateFunction' => $pgTruncateFunction,
+                'razeFunction' => $pgRazeFunction,
             ],
         ];
     }