From 0c410fcf501c821f33f69263ef492a053ba0f6eb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 Mar 2017 18:42:19 -0500 Subject: [PATCH] More binding tests and related changes - Introduced abstract Statement class to hold common methods - Common methods currently consist of a date formatter and type caster - Moved binding tests to a trait for reuse with future drivers --- lib/Database.php | 8 +- lib/Db/AbstractStatement.php | 89 ++++++++ lib/Db/Common.php | 19 -- lib/Db/Driver.php | 10 - lib/Db/Statement.php | 12 +- lib/Db/StatementSQLite3.php | 98 +++----- tests/Db/SQLite3/TestDbStatementSQLite3.php | 52 +---- tests/lib/Db/BindingTests.php | 240 ++++++++++++++++++++ 8 files changed, 369 insertions(+), 159 deletions(-) create mode 100644 lib/Db/AbstractStatement.php create mode 100644 tests/lib/Db/BindingTests.php diff --git a/lib/Database.php b/lib/Database.php index 0b4008d8..8236958c 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -291,16 +291,14 @@ class Database { throw new Feed\Exception($url, $e); } - $this->db->prepare("INSERT INTO newssync_feeds(url,title,favicon,source,updated,modified,etag,username,password) values(?,?,?)", "str", "str", "str", "str", "str", "str", "str", "str", "str")->run( + $this->db->prepare("INSERT INTO newssync_feeds(url,title,favicon,source,updated,modified,etag,username,password) values(?,?,?,?,?,?,?,?,?)", "str", "str", "str", "str", "datetime", "datetime", "str", "str", "str")->run( $url, $feed->title, // Grab the favicon for the Goodfeed; returns an empty string if it cannot find one. (new PicoFeed\Reader\Favicon)->find($url), $feed->siteUrl, - // Convert the date formats to SQL date format before inserting. - // FIXME: Dates should be formatted transparently by the driver's Statement wrapper, not here - $this->driver::formatDate($feed->date), - $this->driver::formatDate($resource->getLastModified()), + $feed->date, + $resource->getLastModified(), $resource->getEtag(), $fetchUser, $fetchPassword diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php new file mode 100644 index 00000000..ef0f2e84 --- /dev/null +++ b/lib/Db/AbstractStatement.php @@ -0,0 +1,89 @@ +runArray($values); + } + + public function rebind(...$bindings): bool { + return $this->rebindArray($bindings); + } + + 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 Exception("paramTypeInvalid", $binding); + $this->types[] = self::TYPES[$binding]; + } + return true; + } + + protected function cast($v, string $t) { + switch($t) { + case "date": + return $this->formatDate($v, self::TS_DATE); + case "time": + return $this->formatDate($v, self::TS_TIME); + case "datetime": + return $this->formatDate($v, self::TS_BOTH); + case "null": + case "integer": + case "float": + case "binary": + case "string": + case "boolean": + if($t=="binary") $t = "string"; + $value = $v; + try{ + settype($value, $t); + } catch(\Throwable $e) { + // handle objects + $value = $v; + if($value instanceof \DateTimeInterface) { + $value = $value->getTimestamp(); + if($t=="string") $value = $this->formatDate($value, self::TS_BOTH); + settype($value, $t); + } else { + $value = null; + settype($value, $t); + } + } + return $value; + default: + throw new Exception("paramTypeUnknown", $type); + } + } + + protected function formatDate($date, int $part = self::TS_BOTH) { + // 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($date instanceof \DateTimeInterface) { + $time = $date->getTimestamp(); + } else if(is_numeric($date)) { + $time = (int) $date; + } else if($date===null) { + return null; + } else if(is_string($date)) { + $time = strtotime($date); + if($time===false) return null; + } else if (is_bool($date)) { + return null; + } else { + $time = (int) $date; + } + // ISO 8601 with space in the middle instead of T. + $date = date($this->dateFormat($part), $time); + date_default_timezone_set($timezone); + return $date; + } +} \ No newline at end of file diff --git a/lib/Db/Common.php b/lib/Db/Common.php index a0dd4e3c..169cbff0 100644 --- a/lib/Db/Common.php +++ b/lib/Db/Common.php @@ -69,23 +69,4 @@ Trait Common { public function prepare(string $query, string ...$paramType): Statement { return $this->prepareArray($query, $paramType); } - - 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(self::TS_FORMAT[$precision], $time); - date_default_timezone_set($timezone); - return $date; - } } \ No newline at end of file diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index f5ded94a..ad4d5fb2 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -3,16 +3,6 @@ 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/Statement.php b/lib/Db/Statement.php index e39bf90b..06058163 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -3,7 +3,9 @@ declare(strict_types=1); namespace JKingWeb\NewsSync\Db; interface Statement { - + const TS_TIME = -1; + const TS_DATE = 0; + const TS_BOTH = 1; const TYPES = [ "null" => "null", "nil" => "null", @@ -20,14 +22,16 @@ interface Statement { "blob" => "binary", "bin" => "binary", "binary" => "binary", - "text" => "text", - "string" => "text", - "str" => "text", + "text" => "string", + "string" => "string", + "str" => "string", "bool" => "boolean", "boolean" => "boolean", "bit" => "boolean", ]; + static function dateFormat(int $part = self::TS_BOTH): string; + 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 7a53a905..23dfb0b9 100644 --- a/lib/Db/StatementSQLite3.php +++ b/lib/Db/StatementSQLite3.php @@ -1,9 +1,20 @@ \SQLITE3_NULL, + "integer" => \SQLITE3_INTEGER, + "float" => \SQLITE3_FLOAT, + "date" => \SQLITE3_TEXT, + "time" => \SQLITE3_TEXT, + "datetime" => \SQLITE3_TEXT, + "binary" => \SQLITE3_BLOB, + "string" => \SQLITE3_TEXT, + "boolean" => \SQLITE3_INTEGER, + ]; + protected $db; protected $st; protected $types; @@ -19,8 +30,12 @@ class StatementSQLite3 implements Statement { unset($this->st); } - public function run(...$values): Result { - return $this->runArray($values); + public static function dateFormat(int $part = self::TS_BOTH): string { + return ([ + self::TS_TIME => 'h:i:sP', + self::TS_DATE => 'Y-m-d', + self::TS_BOTH => 'Y-m-d h:i:sP', + ])[$part]; } public function runArray(array $values = null): Result { @@ -28,80 +43,21 @@ class StatementSQLite3 implements Statement { $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]); + if(!array_key_exists($this->types[$a], self::BINDINGS)) throw new Exception("paramTypeUnknown", $this->types[$a]); + $type = self::BINDINGS[$this->types[$a]]; } else { $type = \SQLITE3_TEXT; } - // cast values if necessary - switch($this->types[$a]) { - case "null": - $value = null; break; - case "integer": - $value = (int) $values[$a]; break; - case "float": - $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": - $value = Driver::formatDate($values[$a], Driver::TS_BOTH); break; - case "binary": - $value = (string) $values[$a]; break; - case "text": - $value = $values[$a]; break; - case "boolean": - $value = (bool) $values[$a]; break; - default: - throw new Exception("paramTypeUnknown", $type); - } - if($type===null) { - $this->st->bindParam($a+1, $value); - } else { - $this->st->bindParam($a+1, $value, $type); - } + // cast value if necessary + $value = $this->cast($values[$a], $this->types[$a]); + // re-adjust for null casts + if($value===null) $type = \SQLITE3_NULL; + // perform binding + $this->st->bindParam($a+1, $value, $type); } 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/tests/Db/SQLite3/TestDbStatementSQLite3.php b/tests/Db/SQLite3/TestDbStatementSQLite3.php index 088c9e06..8b6147e1 100644 --- a/tests/Db/SQLite3/TestDbStatementSQLite3.php +++ b/tests/Db/SQLite3/TestDbStatementSQLite3.php @@ -4,10 +4,11 @@ namespace JKingWeb\NewsSync; class TestDbStatementSQLite3 extends \PHPUnit\Framework\TestCase { - use Test\Tools; + use Test\Tools, Test\Db\BindingTests; protected $c; protected $s; + static protected $imp = Db\StatementSQLite3::class; function setUp() { date_default_timezone_set("UTC"); @@ -28,53 +29,4 @@ class TestDbStatementSQLite3 extends \PHPUnit\Framework\TestCase { function testConstructStatement() { $this->assertInstanceOf(Db\StatementSQLite3::class, new Db\StatementSQLite3($this->c, $this->s)); } - - function testBindMissingValue() { - $s = new Db\StatementSQLite3($this->c, $this->s); - $val = $s->runArray()->get()['value']; - $this->assertSame(null, $val); - } - - 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); - $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() { - $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 diff --git a/tests/lib/Db/BindingTests.php b/tests/lib/Db/BindingTests.php new file mode 100644 index 00000000..a589ce1a --- /dev/null +++ b/tests/lib/Db/BindingTests.php @@ -0,0 +1,240 @@ +c, $this->s); + $val = $s->runArray()->get()['value']; + $this->assertSame(null, $val); + } + + function testBindNull() { + $input = null; + $exp = [ + "null" => null, + "integer" => null, + "float" => null, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => null, + "string" => null, + "boolean" => null, + ]; + $this->checkBinding($input, $exp); + } + + function testBindTrue() { + $input = true; + $exp = [ + "null" => null, + "integer" => 1, + "float" => 1.0, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => "1", + "string" => "1", + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindFalse() { + $input = false; + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => "", + "string" => "", + "boolean" => 0, + ]; + $this->checkBinding($input, $exp); + } + + function testBindInteger() { + $input = 2112; + $exp = [ + "null" => null, + "integer" => 2112, + "float" => 2112.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), 2112), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), 2112), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), 2112), + "binary" => "2112", + "string" => "2112", + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindIntegerZero() { + $input = 0; + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), 0), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), 0), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), 0), + "binary" => "0", + "string" => "0", + "boolean" => 0, + ]; + $this->checkBinding($input, $exp); + } + + function testBindFloat() { + $input = 2112.0; + $exp = [ + "null" => null, + "integer" => 2112, + "float" => 2112.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), 2112), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), 2112), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), 2112), + "binary" => "2112", + "string" => "2112", + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindFloatZero() { + $input = 0.0; + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), 0), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), 0), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), 0), + "binary" => "0", + "string" => "0", + "boolean" => 0, + ]; + $this->checkBinding($input, $exp); + } + + function testBindAsciiString() { + $input = "Random string"; + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => $input, + "string" => $input, + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindUtf8String() { + $input = "é"; + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => $input, + "string" => $input, + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindBinaryString() { + // FIXME: This test may be unreliable; SQLite happily stores invalid UTF-8 text as bytes untouched, but other engines probably don't do this + $input = chr(233); + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => null, + "time" => null, + "datetime" => null, + "binary" => $input, + "string" => $input, + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindIso8601DateString() { + $input = "2017-01-09T13:11:17"; + $time = strtotime($input); + $exp = [ + "null" => null, + "integer" => 2017, + "float" => 2017.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), $time), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), $time), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), $time), + "binary" => $input, + "string" => $input, + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindArbitraryDateString() { + $input = "Today"; + $time = strtotime($input); + $exp = [ + "null" => null, + "integer" => 0, + "float" => 0.0, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), $time), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), $time), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), $time), + "binary" => $input, + "string" => $input, + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindMutableDateObject($class = '\DateTime') { + $input = new $class("Noon Today"); + $time = $input->getTimestamp(); + $exp = [ + "null" => null, + "integer" => $time, + "float" => (float) $time, + "date" => date(self::$imp::dateFormat(Statement::TS_DATE), $time), + "time" => date(self::$imp::dateFormat(Statement::TS_TIME), $time), + "datetime" => date(self::$imp::dateFormat(Statement::TS_BOTH), $time), + "binary" => date(self::$imp::dateFormat(Statement::TS_BOTH), $time), + "string" => date(self::$imp::dateFormat(Statement::TS_BOTH), $time), + "boolean" => 1, + ]; + $this->checkBinding($input, $exp); + } + + function testBindImmutableDateObject() { + $this->testBindMutableDateObject('\DateTimeImmutable'); + } + + protected function checkBinding($input, array $expectations) { + $s = new self::$imp($this->c, $this->s); + $types = array_unique(Statement::TYPES); + foreach($types as $type) { + $s->rebindArray([$type]); + $val = $s->runArray([$input])->get()['value']; + $this->assertSame($expectations[$type], $val, "Type $type failed comparison."); + } + } +} \ No newline at end of file