diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 1dc990f..abf9f77 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -12,11 +12,25 @@ use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractStatement implements Statement { use SQLState; + const TYPE_NORM_MAP = [ + self::T_INTEGER => ValueInfo::M_NULL | ValueInfo::T_INT, + self::T_STRING => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_BOOLEAN => ValueInfo::M_NULL | ValueInfo::T_BOOL, + self::T_DATETIME => ValueInfo::M_NULL | ValueInfo::T_DATE, + self::T_FLOAT => ValueInfo::M_NULL | ValueInfo::T_FLOAT, + self::T_BINARY => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_INTEGER => ValueInfo::T_INT, + self::T_NOT_NULL + self::T_STRING => ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_BOOLEAN => ValueInfo::T_BOOL, + self::T_NOT_NULL + self::T_DATETIME => ValueInfo::T_DATE, + self::T_NOT_NULL + self::T_FLOAT => ValueInfo::T_FLOAT, + self::T_NOT_NULL + self::T_BINARY => ValueInfo::T_STRING, + ]; + protected $types = []; - protected $isNullable = []; abstract public function runArray(array $values = []): Result; - abstract protected function bindValue($value, string $type, int $position): bool; + abstract protected function bindValue($value, int $type, int $position): bool; abstract protected function prepare(string $query): bool; abstract protected static function buildEngineException($code, string $msg): array; @@ -41,18 +55,11 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $this->retypeArray($binding, true); } else { - $binding = trim(strtolower($binding)); - if (strpos($binding, "strict ")===0) { - // "strict" types' values may never be null; null values will later be cast to the type specified - $this->isNullable[] = false; - $binding = substr($binding, 7); - } else { - $this->isNullable[] = true; - } - if (!array_key_exists($binding, self::TYPES)) { + $bindId = self::TYPES[trim(strtolower($binding))] ?? 0; + if (!$bindId) { throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore } - $this->types[] = self::TYPES[$binding]; + $this->types[] = $bindId; } } if (!$append) { @@ -61,27 +68,16 @@ abstract class AbstractStatement implements Statement { return true; } - protected function cast($v, string $t, bool $nullable) { + protected function cast($v, int $t) { switch ($t) { - case "datetime": + case self::T_DATETIME: + return Date::transform($v, "sql"); + case self::T_DATETIME + self::T_NOT_NULL: $v = Date::transform($v, "sql"); - if (is_null($v) && !$nullable) { - $v = 0; - $v = Date::transform($v, "sql"); - } - return $v; - case "integer": - return ValueInfo::normalize($v, ValueInfo::T_INT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "float": - return ValueInfo::normalize($v, ValueInfo::T_FLOAT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "binary": - case "string": - return ValueInfo::normalize($v, ValueInfo::T_STRING | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "boolean": - $v = ValueInfo::normalize($v, ValueInfo::T_BOOL | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - return is_null($v) ? $v : (int) $v; + return $v ? $v : "0001-01-01 00:00:00"; default: - throw new Exception("paramTypeUnknown", $type); // @codeCoverageIgnore + $v = ValueInfo::normalize($v, self::TYPE_NORM_MAP[$t], null, "sql"); + return is_bool($v) ? (int) $v : $v; } } @@ -92,8 +88,8 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $a += $this->bindValues($value, $a); } elseif (array_key_exists($a, $this->types)) { - $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); - $this->bindValue($value, $this->types[$a], ++$a); + $value = $this->cast($value, $this->types[$a]); + $this->bindValue($value, $this->types[$a] % self::T_NOT_NULL, ++$a); } else { throw new Exception("paramTypeMissing", $a+1); } @@ -102,7 +98,7 @@ abstract class AbstractStatement implements Statement { // SQLite will happily substitute null for a missing value, but other engines (viz. PostgreSQL) produce an error if (is_null($offset)) { while ($a < sizeof($this->types)) { - $this->bindValue(null, $this->types[$a], ++$a); + $this->bindValue(null, $this->types[$a] % self::T_NOT_NULL, ++$a); } } return $a - $offset; diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php index 9612615..acbf4a5 100644 --- a/lib/Db/MySQL/Statement.php +++ b/lib/Db/MySQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use ExceptionBuilder; const BINDINGS = [ - "integer" => "i", - "float" => "d", - "datetime" => "s", - "binary" => "b", - "string" => "s", - "boolean" => "i", + self::T_INTEGER => "i", + self::T_FLOAT => "d", + self::T_DATETIME => "s", + self::T_BINARY => "b", + self::T_STRING => "s", + self::T_BOOLEAN => "i", ]; protected $db; @@ -93,11 +93,11 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { // this is a bit of a hack: we collect values (and MySQL bind types) here so that we can take // advantage of the work done by bindValues() even though MySQL requires everything to be bound // all at once; we also segregate large values for later packetization - if (($type === "binary" && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { + if (($type == self::T_BINARY && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { $this->values[] = null; $this->longs[$position - 1] = $value; $this->binds .= "b"; @@ -112,7 +112,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { $out = ""; for ($b = 1; $b < sizeof($query); $b++) { $a = $b - 1; - $mark = (($types[$a] ?? "") === "datetime") ? "cast(? as datetime(0))" : "?"; + $mark = (($types[$a] ?? 0) % self::T_NOT_NULL == self::T_DATETIME) ? "cast(? as datetime(0))" : "?"; $out .= $query[$a].$mark; } $out .= array_pop($query); diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index 594ecf8..2175231 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -10,12 +10,12 @@ abstract class PDOStatement extends AbstractStatement { use PDOError; const BINDINGS = [ - "integer" => \PDO::PARAM_INT, - "float" => \PDO::PARAM_STR, - "datetime" => \PDO::PARAM_STR, - "binary" => \PDO::PARAM_LOB, - "string" => \PDO::PARAM_STR, - "boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => \PDO::PARAM_INT, + self::T_FLOAT => \PDO::PARAM_STR, + self::T_DATETIME => \PDO::PARAM_STR, + self::T_BINARY => \PDO::PARAM_LOB, + self::T_STRING => \PDO::PARAM_STR, + self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $st; @@ -55,7 +55,7 @@ abstract class PDOStatement extends AbstractStatement { return new PDOResult($this->db, $this->st); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \PDO::PARAM_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php index df74e3d..f5040f2 100644 --- a/lib/Db/PostgreSQL/Statement.php +++ b/lib/Db/PostgreSQL/Statement.php @@ -14,12 +14,12 @@ 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 + self::T_INTEGER => "bigint", + self::T_FLOAT => "decimal", + self::T_DATETIME => "timestamp(0) without time zone", + self::T_BINARY => "bytea", + self::T_STRING => "text", + self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $db; @@ -47,7 +47,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { } } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { $this->in[] = $value; return true; } @@ -59,7 +59,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { for ($b = 1; $b < sizeof($q); $b++) { $a = $b - 1; $mark = $mungeParamMarkers ? "\$$b" : "?"; - $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : ""; + $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a] % self::T_NOT_NULL] : ""; $out .= $q[$a].$mark.$type; } $out .= array_pop($q); diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index a0fb0cd..bfae44d 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -17,12 +17,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; const BINDINGS = [ - "integer" => \SQLITE3_INTEGER, - "float" => \SQLITE3_FLOAT, - "datetime" => \SQLITE3_TEXT, - "binary" => \SQLITE3_BLOB, - "string" => \SQLITE3_TEXT, - "boolean" => \SQLITE3_INTEGER, + self::T_INTEGER => \SQLITE3_INTEGER, + self::T_FLOAT => \SQLITE3_FLOAT, + self::T_DATETIME => \SQLITE3_TEXT, + self::T_BINARY => \SQLITE3_BLOB, + self::T_STRING => \SQLITE3_TEXT, + self::T_BOOLEAN => \SQLITE3_INTEGER, ]; protected $db; @@ -68,7 +68,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \SQLITE3_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b59e075..b85ceca 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -8,24 +8,48 @@ namespace JKingWeb\Arsse\Db; interface Statement { const TYPES = [ - "int" => "integer", - "integer" => "integer", - "float" => "float", - "double" => "float", - "real" => "float", - "numeric" => "float", - "datetime" => "datetime", - "timestamp" => "datetime", - "blob" => "binary", - "bin" => "binary", - "binary" => "binary", - "text" => "string", - "string" => "string", - "str" => "string", - "bool" => "boolean", - "boolean" => "boolean", - "bit" => "boolean", + 'int' => self::T_INTEGER, + 'integer' => self::T_INTEGER, + 'float' => self::T_FLOAT, + 'double' => self::T_FLOAT, + 'real' => self::T_FLOAT, + 'numeric' => self::T_FLOAT, + 'datetime' => self::T_DATETIME, + 'timestamp' => self::T_DATETIME, + 'blob' => self::T_BINARY, + 'bin' => self::T_BINARY, + 'binary' => self::T_BINARY, + 'text' => self::T_STRING, + 'string' => self::T_STRING, + 'str' => self::T_STRING, + 'bool' => self::T_BOOLEAN, + 'boolean' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, + 'strict int' => self::T_NOT_NULL + self::T_INTEGER, + 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, + 'strict float' => self::T_NOT_NULL + self::T_FLOAT, + 'strict double' => self::T_NOT_NULL + self::T_FLOAT, + 'strict real' => self::T_NOT_NULL + self::T_FLOAT, + 'strict numeric' => self::T_NOT_NULL + self::T_FLOAT, + 'strict datetime' => self::T_NOT_NULL + self::T_DATETIME, + 'strict timestamp' => self::T_NOT_NULL + self::T_DATETIME, + 'strict blob' => self::T_NOT_NULL + self::T_BINARY, + 'strict bin' => self::T_NOT_NULL + self::T_BINARY, + 'strict binary' => self::T_NOT_NULL + self::T_BINARY, + 'strict text' => self::T_NOT_NULL + self::T_STRING, + 'strict string' => self::T_NOT_NULL + self::T_STRING, + 'strict str' => self::T_NOT_NULL + self::T_STRING, + 'strict bool' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict boolean' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict bit' => self::T_NOT_NULL + self::T_BOOLEAN, ]; + const T_INTEGER = 1; + const T_STRING = 2; + const T_BOOLEAN = 3; + const T_DATETIME = 4; + const T_FLOAT = 5; + const T_BINARY = 6; + const T_NOT_NULL = 100; public function run(...$values): Result; public function runArray(array $values = []): Result; diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index bd719aa..cdc74a7 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -143,7 +143,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'Null as strict integer' => [null, "strict integer", "0"], 'Null as strict float' => [null, "strict float", "0.0"], 'Null as strict string' => [null, "strict string", "''"], - 'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"], + 'Null as strict datetime' => [null, "strict datetime", "'0001-01-01 00:00:00'"], 'Null as strict boolean' => [null, "strict boolean", "0"], 'True as integer' => [true, "integer", "1"], 'True as float' => [true, "float", "1.0"], @@ -153,7 +153,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'True as strict integer' => [true, "strict integer", "1"], 'True as strict float' => [true, "strict float", "1.0"], 'True as strict string' => [true, "strict string", "'1'"], - 'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"], + 'True as strict datetime' => [true, "strict datetime", "'0001-01-01 00:00:00'"], 'True as strict boolean' => [true, "strict boolean", "1"], 'False as integer' => [false, "integer", "0"], 'False as float' => [false, "float", "0.0"], @@ -163,7 +163,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'False as strict integer' => [false, "strict integer", "0"], 'False as strict float' => [false, "strict float", "0.0"], 'False as strict string' => [false, "strict string", "''"], - 'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"], + 'False as strict datetime' => [false, "strict datetime", "'0001-01-01 00:00:00'"], 'False as strict boolean' => [false, "strict boolean", "0"], 'Integer as integer' => [2112, "integer", "2112"], 'Integer as float' => [2112, "float", "2112.0"], @@ -213,7 +213,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'ASCII string as strict integer' => ["Random string", "strict integer", "0"], 'ASCII string as strict float' => ["Random string", "strict float", "0.0"], 'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"], - 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'0001-01-01 00:00:00'"], 'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"], 'UTF-8 string as integer' => ["\u{e9}", "integer", "0"], 'UTF-8 string as float' => ["\u{e9}", "float", "0.0"], @@ -223,7 +223,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"], 'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0"], 'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"], - 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"], + 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'0001-01-01 00:00:00'"], 'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"], 'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"], 'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0"], @@ -306,7 +306,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"], 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], 'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"], - 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], + 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'0001-01-01 00:00:00'"], 'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"], 'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], 'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"],