diff --git a/lib/AbstractException.php b/lib/AbstractException.php index e427e9d..07f7437 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -21,6 +21,8 @@ abstract class AbstractException extends \Exception { "Db/Exception.fileUnwritable" => 10205, "Db/Exception.fileUncreatable" => 10206, "Db/Exception.fileCorrupt" => 10207, + "Db/Exception.paramTypeInvalid" => 10401, + "Db/Exception.paramTypeUnknown" => 10402, "Db/Update/Exception.tooNew" => 10211, "Db/Update/Exception.fileMissing" => 10212, "Db/Update/Exception.fileUnusable" => 10213, diff --git a/lib/Db/Common.php b/lib/Db/Common.php index c3c98d4..a0dd4e3 100644 --- a/lib/Db/Common.php +++ b/lib/Db/Common.php @@ -70,12 +70,21 @@ Trait Common { return $this->prepareArray($query, $paramType); } - public static function formatDate(string $date): string { + public static function formatDate($date, int $precision = self::TS_BOTH): string { // Force UTC. $timezone = date_default_timezone_get(); date_default_timezone_set('UTC'); + // convert input to a Unix timestamp + // FIXME: there are more kinds of date representations + if(is_int($date)) { + $time = $date; + } else if($date===null) { + $time = 0; + } else { + $time = strtotime($date); + } // ISO 8601 with space in the middle instead of T. - $date = date('Y-m-d h:i:sP', strtotime($date)); + $date = date(self::TS_FORMAT[$precision], $time); date_default_timezone_set($timezone); return $date; } diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index ad4d5fb..f5ded94 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -3,6 +3,16 @@ declare(strict_types=1); namespace JKingWeb\NewsSync\Db; interface Driver { + const TS_TIME = -1; + const TS_DATE = 0; + const TS_BOTH = 1; + + const TS_FORMAT = [ + self::TS_TIME => 'h:i:sP', + self::TS_DATE => 'Y-m-d', + self::TS_BOTH => 'Y-m-d h:i:sP', + ]; + function __construct(\JKingWeb\NewsSync\RuntimeData $data, bool $install = false); // returns a human-friendly name for the driver (for display in installer, for example) static function driverName(): string; diff --git a/lib/Db/DriverSQLite3.php b/lib/Db/DriverSQLite3.php index bed2f35..74c969d 100644 --- a/lib/Db/DriverSQLite3.php +++ b/lib/Db/DriverSQLite3.php @@ -8,7 +8,7 @@ class DriverSQLite3 implements Driver { protected $db; protected $data; - private function __construct(\JKingWeb\NewsSync\RuntimeData $data, bool $install = false) { + public function __construct(\JKingWeb\NewsSync\RuntimeData $data, bool $install = false) { // check to make sure required extension is loaded if(!class_exists("SQLite3")) throw new Exception("extMissing", self::driverName()); $this->data = $data; diff --git a/lib/Db/ResultSQLite3.php b/lib/Db/ResultSQLite3.php index 0f2d7ee..caaec61 100644 --- a/lib/Db/ResultSQLite3.php +++ b/lib/Db/ResultSQLite3.php @@ -31,7 +31,7 @@ class ResultSQLite3 implements Result { // constructor/destructor - public function __construct($result, $changes = 0, $statement = null) { + public function __construct(\SQLite3Result $result, int $changes = 0, StatementSQLite3 $statement = null) { $this->st = $statement; //keeps the statement from being destroyed, invalidating the result set $this->set = $result; $this->rows = $changes; diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index 0b028a5..e39bf90 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -3,7 +3,31 @@ declare(strict_types=1); namespace JKingWeb\NewsSync\Db; interface Statement { - function __invoke(...$values); // alias of run() + + const TYPES = [ + "null" => "null", + "nil" => "null", + "int" => "integer", + "integer" => "integer", + "float" => "float", + "double" => "float", + "real" => "float", + "numeric" => "float", + "date" => "date", + "time" => "time", + "datetime" => "datetime", + "timestamp" => "datetime", + "blob" => "binary", + "bin" => "binary", + "binary" => "binary", + "text" => "text", + "string" => "text", + "str" => "text", + "bool" => "boolean", + "boolean" => "boolean", + "bit" => "boolean", + ]; + function run(...$values): Result; function runArray(array $values): Result; function rebind(...$bindings): bool; diff --git a/lib/Db/StatementSQLite3.php b/lib/Db/StatementSQLite3.php index 006b83e..7a53a90 100644 --- a/lib/Db/StatementSQLite3.php +++ b/lib/Db/StatementSQLite3.php @@ -1,13 +1,14 @@ db = $db; $this->st = $st; $this->rebindArray($bindings); @@ -18,10 +19,6 @@ class StatementSQLite3 implements Statement { unset($this->st); } - public function __invoke(...$values) { - return $this->runArray($values); - } - public function run(...$values): Result { return $this->runArray($values); } @@ -30,56 +27,81 @@ class StatementSQLite3 implements Statement { $this->st->clear(); $l = sizeof($values); for($a = 0; $a < $l; $a++) { + // find the right SQLite binding type for the value/specified type + $type = null; if($values[$a]===null) { $type = \SQLITE3_NULL; + } else if(array_key_exists($a,$this->types)) { + $type = $this->translateType($this->types[$a]); } else { - $type = (array_key_exists($a,$this->types)) ? $this->types[$a] : \SQLITE3_TEXT; + $type = \SQLITE3_TEXT; } - $this->st->bindParam($a+1, $values[$a], $type); - } - return new ResultSQLite3($this->st->execute(), $this->db->changes(), $this); - } - - public function rebind(...$bindings): bool { - return $this->rebindArray($bindings); - } - - public function rebindArray(array $bindings): bool { - $this->types = []; - foreach($bindings as $binding) { - switch(trim(strtolower($binding))) { + // cast values if necessary + switch($this->types[$a]) { case "null": - case "nil": - $this->types[] = \SQLITE3_NULL; break; - case "int": + $value = null; break; case "integer": - $this->types[] = \SQLITE3_INTEGER; break; + $value = (int) $values[$a]; break; case "float": - case "double": - case "real": - case "numeric": - $this->types[] = \SQLITE3_FLOAT; break; + $value = (float) $values[$a]; break; case "date": + $value = Driver::formatDate($values[$a], Driver::TS_DATE); break; case "time": + $value = Driver::formatDate($values[$a], Driver::TS_TIME); break; case "datetime": - case "timestamp": - $this->types[] = \SQLITE3_TEXT; break; - case "blob": - case "bin": + $value = Driver::formatDate($values[$a], Driver::TS_BOTH); break; case "binary": - $this->types[] = \SQLITE3_BLOB; break; + $value = (string) $values[$a]; break; case "text": - case "string": - case "str": - $this->types[] = \SQLITE3_TEXT; break; - case "bool": + $value = $values[$a]; break; case "boolean": - case "bit": - $this->types[] = \SQLITE3_INTEGER; break; + $value = (bool) $values[$a]; break; default: - $this->types[] = \SQLITE3_TEXT; break; + throw new Exception("paramTypeUnknown", $type); + } + if($type===null) { + $this->st->bindParam($a+1, $value); + } else { + $this->st->bindParam($a+1, $value, $type); } } - return true; + return new ResultSQLite3($this->st->execute(), $this->db->changes(), $this); + } + + public function rebind(...$bindings): bool { + return $this->rebindArray($bindings); + } + + protected function translateType(string $type) { + switch($type) { + case "null": + return \SQLITE3_NULL; + case "integer": + return \SQLITE3_INTEGER; + case "float": + return \SQLITE3_FLOAT; + case "date": + case "time": + case "datetime": + return \SQLITE3_TEXT; + case "binary": + return \SQLITE3_BLOB; + case "text": + return \SQLITE3_TEXT; + case "boolean": + return \SQLITE3_INTEGER; + default: + throw new Db\Exception("paramTypeUnknown", $binding); + } + } + + public function rebindArray(array $bindings): bool { + $this->types = []; + foreach($bindings as $binding) { + $binding = trim(strtolower($binding)); + if(!array_key_exists($binding, self::TYPES)) throw new Db\Exception("paramTypeInvalid", $binding); + $this->types[] = self::TYPES[$binding]; + } + return true; } } \ No newline at end of file diff --git a/locale/en.php b/locale/en.php index 80d2225..e8564d7 100644 --- a/locale/en.php +++ b/locale/en.php @@ -27,6 +27,8 @@ return [ 'Exception.JKingWeb/NewsSync/Db/Exception.fileUnusable' => 'Insufficient permissions to open database file "{0}" for reading or writing', 'Exception.JKingWeb/NewsSync/Db/Exception.fileUncreatable' => 'Insufficient permissions to create new database file "{0}"', 'Exception.JKingWeb/NewsSync/Db/Exception.fileCorrupt' => 'Database file "{0}" is corrupt or not a valid database', + 'Exception.JKingWeb/NewsSync/Db/Exception.paramTypeInvalid' => 'Prepared statement parameter type "{0}" is invalid', + 'Exception.JKingWeb/NewsSync/Db/Exception.paramTypeUnknown' => 'Prepared statement parameter type "{0}" is valid, but not implemented', 'Exception.JKingWeb/NewsSync/Db/Update/Exception.manual' => '{from_version, select, diff --git a/tests/Db/SQLite3/TestDbStatementSQLite3.php b/tests/Db/SQLite3/TestDbStatementSQLite3.php index 3254b09..088c9e0 100644 --- a/tests/Db/SQLite3/TestDbStatementSQLite3.php +++ b/tests/Db/SQLite3/TestDbStatementSQLite3.php @@ -36,14 +36,45 @@ class TestDbStatementSQLite3 extends \PHPUnit\Framework\TestCase { } function testBindNull() { + $exp = [ + "null" => null, + "integer" => null, + "float" => null, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => null, + "text" => null, + "boolean" => null, + ]; $s = new Db\StatementSQLite3($this->c, $this->s); - $val = $s->runArray([null])->get()['value']; - $this->assertSame(null, $val); + $types = array_unique(Db\Statement::TYPES); + foreach($types as $type) { + $s->rebindArray([$type]); + $val = $s->runArray([null])->get()['value']; + $this->assertSame($exp[$type], $val); + } } function testBindInteger() { - $s = new Db\StatementSQLite3($this->c, $this->s, ["int"]); - $val = $s->runArray([2112])->get()['value']; - $this->assertSame(2112, $val); + $exp = [ + "null" => null, + "integer" => 2112, + "float" => 2112.0, + "date" => date('Y-m-d', 2112), + "time" => date('h:i:sP', 2112), + "datetime" => date('Y-m-d h:i:sP', 2112), + "binary" => "2112", + "text" => "2112", + "boolean" => 1, + ]; + $s = new Db\StatementSQLite3($this->c, $this->s); + $types = array_unique(Db\Statement::TYPES); + foreach($types as $type) { + $s->rebindArray([$type]); + $val = $s->runArray([2112])->get()['value']; + $this->assertSame($exp[$type], $val, "Type $type failed comparison."); + } } + } \ No newline at end of file