FIX: Fix PDO cached statement column coercion

NEW: Add PDOStatementHandle class that is now what PDOQuery expects
This commit is contained in:
Sam Minnee 2018-11-16 18:10:58 +13:00
parent 2625cea5e3
commit c9c7c0c825
3 changed files with 193 additions and 84 deletions

View File

@ -98,7 +98,7 @@ class PDOConnector extends DBConnector implements TransactionManager
* one exists for the given query * one exists for the given query
* *
* @param string $sql * @param string $sql
* @return PDOStatement * @return PDOStatementHandle|false
*/ */
public function getOrPrepareStatement($sql) public function getOrPrepareStatement($sql)
{ {
@ -113,11 +113,14 @@ class PDOConnector extends DBConnector implements TransactionManager
array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY) array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY)
); );
// Wrap in a PDOStatementHandle, to cache column metadata
$statementHandle = ($statement === false) ? false : new PDOStatementHandle($statement);
// Only cache select statements // Only cache select statements
if (preg_match('/^(\s*)select\b/i', $sql)) { if (preg_match('/^(\s*)select\b/i', $sql)) {
$this->cachedStatements[$sql] = $statement; $this->cachedStatements[$sql] = $statementHandle;
} }
return $statement; return $statementHandle;
} }
/** /**
@ -214,7 +217,10 @@ class PDOConnector extends DBConnector implements TransactionManager
$options[PDO::MYSQL_ATTR_SSL_CA] = $parameters['ssl_ca']; $options[PDO::MYSQL_ATTR_SSL_CA] = $parameters['ssl_ca'];
} }
// use default cipher if not provided // use default cipher if not provided
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = array_key_exists('ssl_cipher', $parameters) ? $parameters['ssl_cipher'] : self::config()->get('ssl_cipher_default'); $options[PDO::MYSQL_ATTR_SSL_CIPHER] =
array_key_exists('ssl_cipher', $parameters) ?
$parameters['ssl_cipher'] :
self::config()->get('ssl_cipher_default');
} }
if (static::config()->get('legacy_types')) { if (static::config()->get('legacy_types')) {
@ -328,7 +334,11 @@ class PDOConnector extends DBConnector implements TransactionManager
$statement = $this->pdoConnection->query($sql); $statement = $this->pdoConnection->query($sql);
// Generate results // Generate results
return $this->prepareResults($statement, $errorLevel, $sql); if ($statement === false) {
$this->databaseError($this->getLastError(), $errorLevel, $sql);
} else {
return $this->prepareResults(new PDOStatementHandle($statement), $errorLevel, $sql);
}
} }
/** /**
@ -395,54 +405,51 @@ class PDOConnector extends DBConnector implements TransactionManager
{ {
$this->beforeQuery($sql); $this->beforeQuery($sql);
// Prepare statement // Fetch cached statement, or create it
$statement = $this->getOrPrepareStatement($sql); $statementHandle = $this->getOrPrepareStatement($sql);
// Bind and invoke statement safely // Error handling
if ($statement) { if ($statementHandle === false) {
$this->bindParameters($statement, $parameters); $this->databaseError($this->getLastError(), $errorLevel, $sql, $this->parameterValues($parameters));
$statement->execute($parameters); return null;
} }
// Bind parameters
$this->bindParameters($statementHandle->getPDOStatement(), $parameters);
$statementHandle->execute($parameters);
// Generate results // Generate results
return $this->prepareResults($statement, $errorLevel, $sql); return $this->prepareResults($statementHandle, $errorLevel, $sql);
} }
/** /**
* Given a PDOStatement that has just been executed, generate results * Given a PDOStatement that has just been executed, generate results
* and report any errors * and report any errors
* *
* @param PDOStatement $statement * @param PDOStatementHandle $statement
* @param int $errorLevel * @param int $errorLevel
* @param string $sql * @param string $sql
* @param array $parameters * @param array $parameters
* @return PDOQuery * @return PDOQuery
*/ */
protected function prepareResults($statement, $errorLevel, $sql, $parameters = array()) protected function prepareResults(PDOStatementHandle $statement, $errorLevel, $sql, $parameters = array())
{ {
// Record row-count and errors of last statement // Catch error
if ($this->hasError($statement)) { if ($this->hasError($statement)) {
$this->lastStatementError = $statement->errorInfo(); $this->lastStatementError = $statement->errorInfo();
} elseif ($statement) { $statement->closeCursor();
$this->databaseError($this->getLastError(), $errorLevel, $sql, $this->parameterValues($parameters));
return null;
}
// Count and return results // Count and return results
$this->rowCount = $statement->rowCount(); $this->rowCount = $statement->rowCount();
return new PDOQuery($statement); return new PDOQuery($statement);
} }
// Ensure statement is closed
if ($statement) {
$statement->closeCursor();
}
// Report any errors
if ($parameters) {
$parameters = $this->parameterValues($parameters);
}
$this->databaseError($this->getLastError(), $errorLevel, $sql, $parameters);
return null;
}
/** /**
* Determine if a resource has an attached error * Determine if a resource has an attached error
* *

View File

@ -2,82 +2,30 @@
namespace SilverStripe\ORM\Connect; namespace SilverStripe\ORM\Connect;
use PDOStatement;
use PDO;
/** /**
* A result-set from a PDO database. * A result-set from a PDO database.
*/ */
class PDOQuery extends Query class PDOQuery extends Query
{ {
/** /**
* The internal MySQL handle that points to the result set. * @var array
* @var PDOStatement
*/ */
protected $statement = null;
protected $results = null; protected $results = null;
protected static $type_mapping = [
// PGSQL
'float8' => 'float',
'float16' => 'float',
'numeric' => 'float',
// MySQL
'NEWDECIMAL' => 'float',
// SQlite
'integer' => 'int',
'double' => 'float',
];
/** /**
* Hook the result-set given into a Query class, suitable for use by SilverStripe. * Hook the result-set given into a Query class, suitable for use by SilverStripe.
* @param PDOStatement $statement The internal PDOStatement containing the results * @param PDOStatement $statement The internal PDOStatement containing the results
*/ */
public function __construct(PDOStatement $statement) public function __construct(PDOStatementHandle $statement)
{ {
$this->statement = $statement;
// Since no more than one PDOStatement for any one connection can be safely // Since no more than one PDOStatement for any one connection can be safely
// traversed, each statement simply requests all rows at once for safety. // traversed, each statement simply requests all rows at once for safety.
// This could be re-engineered to call fetchAll on an as-needed basis // This could be re-engineered to call fetchAll on an as-needed basis
$this->results = $this->typeCorrectedFetchAll($statement); $this->results = $statement->typeCorrectedFetchAll();
$statement->closeCursor(); $statement->closeCursor();
} }
/**
* Fetch a record form the statement with its type data corrected
* Returns data as an array of maps
* @return array
*/
protected function typeCorrectedFetchAll($statement)
{
$columnCount = $statement->columnCount();
$columnMeta = [];
for ($i = 0; $i<$columnCount; $i++) {
$columnMeta[$i] = $statement->getColumnMeta($i);
}
// Re-map fetched data using columnMeta
return array_map(
function ($rowArray) use ($columnMeta) {
$row = [];
foreach ($columnMeta as $i => $meta) {
// Coerce any column types that aren't correctly retrieved from the database
if (isset($meta['native_type']) && isset(self::$type_mapping[$meta['native_type']])) {
settype($rowArray[$i], self::$type_mapping[$meta['native_type']]);
}
$row[$meta['name']] = $rowArray[$i];
}
return $row;
},
$statement->fetchAll(PDO::FETCH_NUM)
);
}
public function seek($row) public function seek($row)
{ {
$this->rowNum = $row - 1; $this->rowNum = $row - 1;

View File

@ -0,0 +1,154 @@
<?php
namespace SilverStripe\ORM\Connect;
use PDO;
use PDOStatement;
/**
* A handle to a PDOStatement, with cached column metadata, and type conversion
*
* Column metadata can't be fetched from a native PDOStatement after multiple calls in some DB backends,
* so we wrap in this handle object, which also takes care of tidying up content types to keep in line
* with the SilverStripe 4.4+ type expectations.
*/
class PDOStatementHandle
{
/**
* The statement to provide a handle to
*
* @var PDOStatement
*/
private $statement;
/**
* Cached column metadata
*
* @var array
*/
private $columnMeta = null;
/**
* Create a new handle.
*
* @param $statement The statement to provide a handle to
*/
public function __construct(PDOStatement $statement)
{
$this->statement = $statement;
}
/**
* Mapping of PDO-reported "native types" to PHP types
*/
protected static $type_mapping = [
// PGSQL
'float8' => 'float',
'float16' => 'float',
'numeric' => 'float',
// MySQL
'NEWDECIMAL' => 'float',
// SQlite
'integer' => 'int',
'double' => 'float',
];
/**
* Fetch a record form the statement with its type data corrected
* Returns data as an array of maps
* @return array
*/
public function typeCorrectedFetchAll()
{
if ($this->columnMeta === null) {
$columnCount = $this->statement->columnCount();
$this->columnMeta = [];
for ($i = 0; $i<$columnCount; $i++) {
$this->columnMeta[$i] = $this->statement->getColumnMeta($i);
}
}
// Re-map fetched data using columnMeta
return array_map(
function ($rowArray) {
$row = [];
foreach ($this->columnMeta as $i => $meta) {
// Coerce any column types that aren't correctly retrieved from the database
if (isset($meta['native_type']) && isset(self::$type_mapping[$meta['native_type']])) {
settype($rowArray[$i], self::$type_mapping[$meta['native_type']]);
}
$row[$meta['name']] = $rowArray[$i];
}
return $row;
},
$this->statement->fetchAll(PDO::FETCH_NUM)
);
}
/**
* Closes the cursor, enabling the statement to be executed again (PDOStatement::closeCursor)
*
* @return bool Returns true on success
*/
public function closeCursor()
{
return $this->statement->closeCursor();
}
/**
* Fetch the SQLSTATE associated with the last operation on the statement handle
* (PDOStatement::errorCode)
*
* @return string
*/
public function errorCode()
{
return $this->statement->errorCode();
}
/**
* Fetch extended error information associated with the last operation on the statement handle
* (PDOStatement::errorInfo)
*
* @return array
*/
public function errorInfo()
{
return $this->statement->errorInfo();
}
/**
* Returns the number of rows affected by the last SQL statement (PDOStatement::rowCount)
*
* @return int
*/
public function rowCount()
{
return $this->statement->rowCount();
}
/**
* Executes a prepared statement (PDOStatement::execute)
*
* @param $parameters An array of values with as many elements as there are bound parameters in the SQL statement
* being executed
* @return bool Returns true on success
*/
public function execute(array $parameters)
{
return $this->statement->execute($parameters);
}
/**
* Return the PDOStatement that this object provides a handle to
*
* @return PDOStatement
*/
public function getPDOStatement()
{
return $this->statement;
}
}