diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 4eee0ffe..01c4e1be 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -55,6 +55,7 @@ abstract class AbstractException extends \Exception { "Db/ExceptionInput.circularDependence" => 10238, "Db/ExceptionInput.subjectMissing" => 10239, "Db/ExceptionTimeout.general" => 10241, + "Db/ExceptionTimeout.logicalLock" => 10241, "Conf/Exception.fileMissing" => 10301, "Conf/Exception.fileUnusable" => 10302, "Conf/Exception.fileUnreadable" => 10303, diff --git a/lib/Conf.php b/lib/Conf.php index 4572cd39..d252a871 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -43,6 +43,16 @@ class Conf { 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 Host name, address, or socket path of MySQL/MariaDB database server (if using MySQL/MariaDB) */ + public $dbMySQLHost = "localhost"; + /** @var string Log-in user name for MySQL/MariaDB database server (if using MySQL/MariaDB) */ + public $dbMySQLUser = "arsse"; + /** @var string Log-in password for MySQL/MariaDB database server (if using MySQL/MariaDB) */ + public $dbMySQLPass = ""; + /** @var integer Listening port for MySQL/MariaDB database server (if using MySQL/MariaDB over TCP) */ + public $dbMySQLPort = 3306; + /** @var string Database name on MySQL/MariaDB database server (if using MySQL/MariaDB) */ + public $dbMySQLDb = "arsse"; /** @var string Class of the user management driver in use (Internal by default) */ public $userDriver = User\Internal\Driver::class; diff --git a/lib/Db/AbstractDriver.php b/lib/Db/AbstractDriver.php index 6dbfb0a5..7ce30e02 100644 --- a/lib/Db/AbstractDriver.php +++ b/lib/Db/AbstractDriver.php @@ -75,8 +75,13 @@ abstract class AbstractDriver implements Driver { $this->lock(); $this->locked = true; } - // create a savepoint, incrementing the transaction depth - $this->exec("SAVEPOINT arsse_".(++$this->transDepth)); + if ($this->locked && static::TRANSACTIONAL_LOCKS == false) { + // if locks are not compatible with transactions (and savepoints), don't actually create a savepoint) + $this->transDepth++; + } else { + // 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 @@ -89,8 +94,13 @@ abstract class AbstractDriver implements Driver { 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); + if ($this->locked && static::TRANSACTIONAL_LOCKS == false) { + // if locks are not compatible with transactions, do nothing + } else { + // release the requested savepoint + $this->exec("RELEASE SAVEPOINT arsse_".$index); + } + // set its state to committed $this->transStatus[$index] = self::TR_COMMIT; // for any later pending savepoints, set their state to implicitly committed $a = $index; @@ -142,8 +152,14 @@ abstract class AbstractDriver implements Driver { if (array_key_exists($index, $this->transStatus)) { switch ($this->transStatus[$index]) { case self::TR_PEND: - $this->exec("ROLLBACK TRANSACTION TO SAVEPOINT arsse_".$index); - $this->exec("RELEASE SAVEPOINT arsse_".$index); + if ($this->locked && static::TRANSACTIONAL_LOCKS == false) { + // if locks are not compatible with transactions, do nothing and report failure as a rollback cannot occur + $out = false; + } else { + // roll back and then erase the savepoint + $this->exec("ROLLBACK TO SAVEPOINT arsse_".$index); + $this->exec("RELEASE SAVEPOINT arsse_".$index); + } $this->transStatus[$index] = self::TR_ROLLBACK; $a = $index; while (++$a && $a <= $this->transDepth) { @@ -151,7 +167,7 @@ abstract class AbstractDriver implements Driver { $this->transStatus[$a] = self::TR_PEND_ROLLBACK; } } - $out = true; + $out = $out ?? true; break; case self::TR_PEND_COMMIT: $this->transStatus[$index] = self::TR_ROLLBACK; diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php new file mode 100644 index 00000000..80feff70 --- /dev/null +++ b/lib/Db/MySQL/Driver.php @@ -0,0 +1,163 @@ +dbMySQLHost; + if ($host[0] == "/") { + // host is a socket + $socket = $host; + $host = ""; + } elseif(substr($host, 0, 9) == "\\\\.\\pipe\\") { + // host is a Windows named piple + $socket = substr($host, 10); + $host = ""; + } + $user = Arsse::$conf->dbMySQLUser ?? ""; + $pass = Arsse::$conf->dbMySQLPass ?? ""; + $port = Arsse::$conf->dbMySQLPost ?? 3306; + $db = Arsse::$conf->dbMySQLDb ?? "arsse"; + $this->makeConnection($user, $pass, $db, $host, $port, $socket ?? ""); + $this->exec("SET lock_wait_timeout = 1"); + } + + /** @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 "MySQL"; + } + + public function charsetAcceptable(): bool { + return true; + } + + public function schemaVersion(): int { + if ($this->query("SELECT count(*) from information_schema.tables where table_name = 'arsse_meta'")->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 '"utf8mb4_unicode_nopad_ci"'; + default: + return $token; + } + } + + public function savepointCreate(bool $lock = false): int { + if (!$this->transStart && !$lock) { + $this->exec("BEGIN"); + $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 { + $tables = $this->query("SELECT table_name as name from information_schema.tables where table_schema = database() and table_name like 'arsse_%'")->getAll(); + if ($tables) { + $tables = array_column($tables, "name"); + $tables = array_map(function($table) { + $table = str_replace('"', '""', $table); + return "\"$table\" write"; + }, $tables); + $tables = implode(", ", $tables); + try { + $this->exec("SET lock_wait_timeout = 1; LOCK TABLES $tables"); + } finally { + $this->exec("SET lock_wait_timeout = 0"); + } + } + return true; + } + + protected function unlock(bool $rollback = false): bool { + $this->exec("UNLOCK TABLES"); + return true; + } + + public function __destruct() { + if (isset($this->db)) { + $this->db->close(); + unset($this->db); + } + } + + public static function driverName(): string { + return Arsse::$lang->msg("Driver.Db.MySQL.Name"); + } + + public static function requirementsMet(): bool { + return false; + } + + protected function makeConnection(string $db, string $user, string $password, string $host, int $port, string $socket) { + } + + protected function getError(): string { + } + + public function exec(string $query): bool { + } + + public function query(string $query): \JKingWeb\Arsse\Db\Result { + } + + public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { + } +} diff --git a/lib/Db/MySQL/PDODriver.php b/lib/Db/MySQL/PDODriver.php new file mode 100644 index 00000000..d568468b --- /dev/null +++ b/lib/Db/MySQL/PDODriver.php @@ -0,0 +1,59 @@ +db = new \PDO($dsn, $user, $password, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode = '".self::SQL_MODE."'", + ]); + } + + 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.MySQLPDO.Name"); + } +} diff --git a/lib/Db/PDOError.php b/lib/Db/PDOError.php index 7e8252d2..6cb615c9 100644 --- a/lib/Db/PDOError.php +++ b/lib/Db/PDOError.php @@ -6,6 +6,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db; +use JKingWeb\Arsse\Db\SQLite3\Driver as SQLite3; + trait PDOError { public function exceptionBuild(bool $statementError = null): array { if ($statementError ?? ($this instanceof Statement)) { @@ -14,6 +16,7 @@ trait PDOError { $err = $this->db->errorInfo(); } switch ($err[0]) { + case "22007": case "22P02": case "42804": return [ExceptionInput::class, 'engineTypeViolation', $err[2]]; @@ -29,18 +32,22 @@ trait PDOError { switch ($this->db->getAttribute(\PDO::ATTR_DRIVER_NAME)) { case "sqlite": switch ($err[1]) { - case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_BUSY: + case SQLite3::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $err[2]]; - case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_MISMATCH: + case SQLite3::SQLITE_MISMATCH: return [ExceptionInput::class, 'engineTypeViolation', $err[2]]; - default: - return [Exception::class, "engineErrorGeneral", $err[1]." - ".$err[2]]; } - // no break - default: - return [Exception::class, "engineErrorGeneral", $err[2]]; // @codeCoverageIgnore + break; + case "mysql": + switch ($err[1]) { + case 1205: + return [ExceptionTimeout::class, 'general', $err[2]]; + case 1364: + return [ExceptionInput::class, "constraintViolation", $err[2]]; + } + break; } - // no break + return [Exception::class, "engineErrorGeneral", $err[0]."/".$err[1].": ".$err[2]]; // @codeCoverageIgnore default: return [Exception::class, "engineErrorGeneral", $err[0].": ".$err[2]]; // @codeCoverageIgnore } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 89e7a7cb..986eeed3 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -15,6 +15,8 @@ use JKingWeb\Arsse\Db\ExceptionTimeout; class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { use Dispatch; + const TRANSACTIONAL_LOCKS = true; + protected $db; protected $transStart = 0; diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index cee1b1ca..95fd7d9d 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -14,6 +14,8 @@ use JKingWeb\Arsse\Db\ExceptionTimeout; class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { use ExceptionBuilder; + const TRANSACTIONAL_LOCKS = true; + const SQLITE_BUSY = 5; const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; diff --git a/locale/en.php b/locale/en.php index dc5381f1..51cb71da 100644 --- a/locale/en.php +++ b/locale/en.php @@ -22,6 +22,8 @@ return [ 'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)', 'Driver.Db.PostgreSQL.Name' => 'PostgreSQL', 'Driver.Db.PostgreSQLPDO.Name' => 'PostgreSQL (PDO)', + 'Driver.Db.MySQL.Name' => 'MySQL/MariaDB', + 'Driver.Db.MySQLPDO.Name' => 'MySQL/MariaDB (PDO)', 'Driver.Service.Curl.Name' => 'HTTP (curl)', 'Driver.Service.Internal.Name' => 'Internal', 'Driver.User.Internal.Name' => 'Internal', @@ -163,6 +165,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineConstraintViolation' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineTypeViolation' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.general' => '{0}', + 'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.logicalLock' => 'Database is locked', 'Exception.JKingWeb/Arsse/User/Exception.alreadyExists' => 'Could not perform action "{action}" because the user {user} already exists', 'Exception.JKingWeb/Arsse/User/Exception.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist', 'Exception.JKingWeb/Arsse/User/Exception.authMissing' => 'Please log in to proceed', diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 5f309916..91992e45 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -11,6 +11,7 @@ use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Test\DatabaseInformation; abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { + protected static $insertDefaultValues = "INSERT INTO arsse_test default values"; protected static $dbInfo; protected static $interface; protected $drv; @@ -39,8 +40,8 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { } // 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')", + "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; @@ -115,14 +116,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $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); + $this->drv->exec("INSERT INTO arsse_meta(\"key\", value) values('lock', '1')"); } 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"); + $this->drv->exec(static::$insertDefaultValues); } public function testExecTypeViolation() { @@ -140,18 +140,10 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $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"); + $this->drv->query(static::$insertDefaultValues); } public function testQueryTypeViolation() { @@ -220,23 +212,21 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr->commit(); @@ -246,10 +236,9 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr->rollback(); @@ -259,28 +248,26 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $this->assertEquals(2, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2->commit(); @@ -291,14 +278,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $this->assertEquals(2, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr1->commit(); @@ -308,14 +294,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $this->assertEquals(2, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2->rollback(); @@ -328,14 +313,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $this->assertEquals(2, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr1->rollback(); @@ -348,14 +332,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { 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->drv->query(static::$insertDefaultValues); $this->assertEquals(1, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2 = $this->drv->begin(); - $this->drv->query($insert); + $this->drv->query(static::$insertDefaultValues); $this->assertEquals(2, $this->drv->query($select)->getValue()); $this->assertEquals(0, $this->query($select)); $tr2->rollback(); diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php index bb1725bf..d849060c 100644 --- a/tests/cases/Db/BaseResult.php +++ b/tests/cases/Db/BaseResult.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Test\DatabaseInformation; abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest { + protected static $insertDefault = "INSERT INTO arsse_test default values"; protected static $dbInfo; protected static $interface; protected $resultClass; @@ -57,17 +58,17 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest { public function testGetChangeCountAndLastInsertId() { $this->makeResult(static::$createMeta); - $r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_meta(key,value) values('test', 1)")); + $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")); + $r = new $this->resultClass(...$this->makeResult(static::$insertDefault)); $this->assertSame(1, $r->changes()); $this->assertSame(1, $r->lastId()); - $r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_test default values")); + $r = new $this->resultClass(...$this->makeResult(static::$insertDefault)); $this->assertSame(1, $r->changes()); $this->assertSame(2, $r->lastId()); } diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index c3c3704e..15abab3c 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -124,8 +124,8 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { } 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"])); + (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]); } diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php index 28cde468..54855196 100644 --- a/tests/cases/Db/BaseUpdate.php +++ b/tests/cases/Db/BaseUpdate.php @@ -81,7 +81,7 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest { } public function testLoadIncompleteFile() { - file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);"); + file_put_contents($this->path."0.sql", "create table arsse_meta(\"key\" varchar(255) primary key not null, value text);"); $this->assertException("updateFileIncomplete", "Db"); $this->drv->schemaUpdate(1, $this->base); } @@ -100,7 +100,7 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest { 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'"); + 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); diff --git a/tests/cases/Db/MySQLPDO/TestDriver.php b/tests/cases/Db/MySQLPDO/TestDriver.php new file mode 100644 index 00000000..3873338c --- /dev/null +++ b/tests/cases/Db/MySQLPDO/TestDriver.php @@ -0,0 +1,20 @@ + + * @covers \JKingWeb\Arsse\Db\PDODriver + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver { + protected static $implementation = "PDO MySQL"; + protected $create = "CREATE TABLE arsse_test(id bigint auto_increment primary key)"; + protected $lock = ["SET lock_wait_timeout = 1", "LOCK TABLES arsse_meta WRITE"]; + protected $setVersion = "UPDATE arsse_meta set value = '#' where `key` = 'schema_version'"; + protected static $insertDefaultValues = "INSERT INTO arsse_test(id) values(default)"; +} diff --git a/tests/cases/Db/MySQLPDO/TestResult.php b/tests/cases/Db/MySQLPDO/TestResult.php new file mode 100644 index 00000000..7777f994 --- /dev/null +++ b/tests/cases/Db/MySQLPDO/TestResult.php @@ -0,0 +1,25 @@ + + */ +class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult { + protected static $implementation = "PDO MySQL"; + protected static $createMeta = "CREATE TABLE arsse_meta(`key` varchar(255) primary key not null, value text)"; + protected static $createTest = "CREATE TABLE arsse_test(id bigint auto_increment primary key)"; + protected static $insertDefault = "INSERT INTO arsse_test(id) values(default)"; + + protected function makeResult(string $q): array { + $set = static::$interface->query($q); + return [static::$interface, $set]; + } +} diff --git a/tests/cases/Db/MySQLPDO/TestStatement.php b/tests/cases/Db/MySQLPDO/TestStatement.php new file mode 100644 index 00000000..6be598a5 --- /dev/null +++ b/tests/cases/Db/MySQLPDO/TestStatement.php @@ -0,0 +1,33 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement { + protected static $implementation = "PDO MySQL"; + + protected function makeStatement(string $q, array $types = []): array { + return [static::$interface, static::$interface->prepare($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 "'".\IntlChar::chr((int) $match[1])."'"; + } + return $value; + default: + return $value; + } + } +} diff --git a/tests/cases/Db/MySQLPDO/TestUpdate.php b/tests/cases/Db/MySQLPDO/TestUpdate.php new file mode 100644 index 00000000..d168f5ce --- /dev/null +++ b/tests/cases/Db/MySQLPDO/TestUpdate.php @@ -0,0 +1,17 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate { + protected static $implementation = "PDO MySQL"; + protected static $minimal1 = "CREATE TABLE arsse_meta(`key` varchar(255) 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/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 9ec5ae57..80bebe11 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -48,6 +48,9 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { 'dbPostgreSQLPass' => "arsse_test", 'dbPostgreSQLDb' => "arsse_test", 'dbPostgreSQLSchema' => "arsse_test", + 'dbMySQLUser' => "arsse_test", + 'dbMySQLPass' => "arsse_test", + 'dbMySQLDb' => "arsse_test", ]; Arsse::$conf = ($force ? null : Arsse::$conf) ?? (new Conf)->import($defaults)->import($conf); } diff --git a/tests/lib/DatabaseInformation.php b/tests/lib/DatabaseInformation.php index ea70afc0..0350362f 100644 --- a/tests/lib/DatabaseInformation.php +++ b/tests/lib/DatabaseInformation.php @@ -27,7 +27,7 @@ class DatabaseInformation { if (!isset(self::$data)) { self::$data = self::getData(); } - if (!isset(self::$data[$name])) { + if (!array_key_exists($name, self::$data)) { throw new \Exception("Invalid database driver name"); } $this->name = $name; @@ -162,6 +162,48 @@ class DatabaseInformation { $pgExecFunction($db, $st); } }; + $mysqlTableList = function($db): array { + $listTables = "SELECT table_name as name from information_schema.tables where table_schema = database() and table_name like 'arsse_%'"; + if ($db instanceof Driver) { + $tables = $db->query($listTables)->getAll(); + } elseif ($db instanceof \PDO) { + $tables = $db->query($listTables)->fetchAll(\PDO::FETCH_ASSOC); + } else { + $tables = $db->query($listTables)->fetch_all(\MYSQLI_ASSOC); + } + $tables = sizeof($tables) ? array_column($tables, "name") : []; + return $tables; + }; + $mysqlTruncateFunction = function($db, array $afterStatements = []) use ($mysqlTableList) { + // rollback any pending transaction + try { + $db->query("UNLOCK TABLES; ROLLBACK"); + } catch (\Throwable $e) { + } + foreach ($mysqlTableList($db) as $table) { + if ($table == "arsse_meta") { + $db->query("DELETE FROM $table where `key` <> 'schema_version'"); + } else { + $db->query("DELETE FROM $table"); + } + } + foreach ($afterStatements as $st) { + $db->query($st); + } + }; + $mysqlRazeFunction = function($db, array $afterStatements = []) use ($mysqlTableList) { + // rollback any pending transaction + try { + $db->query("UNLOCK TABLES; ROLLBACK"); + } catch (\Throwable $e) { + } + foreach ($mysqlTableList($db) as $table) { + $db->query("DROP TABLE IF EXISTS $table"); + } + foreach ($afterStatements as $st) { + $db->query($st); + } + }; return [ 'SQLite 3' => [ 'pdo' => false, @@ -244,6 +286,34 @@ class DatabaseInformation { 'truncateFunction' => $pgTruncateFunction, 'razeFunction' => $pgRazeFunction, ], + 'PDO MySQL' => [ + 'pdo' => true, + 'backend' => "MySQL", + 'statementClass' => \JKingWeb\Arsse\Db\PDOStatement::class, + 'resultClass' => \JKingWeb\Arsse\Db\PDOResult::class, + 'driverClass' => \JKingWeb\Arsse\Db\MySQL\PDODriver::class, + 'stringOutput' => true, + 'interfaceConstructor' => function() { + try { + $dsn = []; + $params = [ + 'charset' => "utf8mb4", + 'host' => Arsse::$conf->dbMySQLHost, + 'port' => Arsse::$conf->dbMySQLPort, + 'dbname' => Arsse::$conf->dbMySQLDb, + ]; + foreach ($params as $k => $v) { + $dsn[] = "$k=$v"; + } + $dsn = "mysql:".implode(";", $dsn); + return new \PDO($dsn, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::MYSQL_ATTR_MULTI_STATEMENTS => false, \PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode = '".\JKingWeb\Arsse\Db\MySQL\PDODriver::SQL_MODE."'",]); + } catch (\Throwable $e) { + return; + } + }, + 'truncateFunction' => $mysqlTruncateFunction, + 'razeFunction' => $mysqlRazeFunction, + ], ]; } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index ceca94ac..3410ac82 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -68,6 +68,12 @@ cases/Db/PostgreSQLPDO/TestCreation.php cases/Db/PostgreSQLPDO/TestDriver.php cases/Db/PostgreSQLPDO/TestUpdate.php + + cases/Db/MySQLPDO/TestResult.php + cases/Db/MySQLPDO/TestStatement.php + cases/Db/MySQLPDO/TestCreation.php + cases/Db/MySQLPDO/TestDriver.php + cases/Db/MySQLPDO/TestUpdate.php cases/Db/SQLite3/TestDatabase.php