Browse Source

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
microsub
J. King 7 years ago
parent
commit
0c410fcf50
  1. 8
      lib/Database.php
  2. 89
      lib/Db/AbstractStatement.php
  3. 19
      lib/Db/Common.php
  4. 10
      lib/Db/Driver.php
  5. 12
      lib/Db/Statement.php
  6. 98
      lib/Db/StatementSQLite3.php
  7. 52
      tests/Db/SQLite3/TestDbStatementSQLite3.php
  8. 240
      tests/lib/Db/BindingTests.php

8
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

89
lib/Db/AbstractStatement.php

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace JKingWeb\NewsSync\Db;
abstract class AbstractStatement implements Statement {
abstract function runArray(array $values): Result;
abstract static function dateFormat(int $part = self::TS_BOTH): string;
public function run(...$values): Result {
return $this->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;
}
}

19
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;
}
}

10
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;

12
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;

98
lib/Db/StatementSQLite3.php

@ -1,9 +1,20 @@
<?php
declare(strict_types=1);
namespace JKingWeb\NewsSync\Db;
use JKingWeb\NewsSync\Db\DriverSQLite3 as Driver;
class StatementSQLite3 implements Statement {
class StatementSQLite3 extends AbstractStatement {
const BINDINGS = [
"null" => \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;
}
}

52
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.");
}
}
}

240
tests/lib/Db/BindingTests.php

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace JKingWeb\NewsSync\Test\Db;
use JKingWeb\NewsSync\Db\Statement;
use JKingWeb\NewsSync\Db\Driver;
trait BindingTests {
function testBindMissingValue() {
$s = new self::$imp($this->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.");
}
}
}
Loading…
Cancel
Save