1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-13 18:24:57 +02:00

Some updates to base DatabaseQuery class to improve bound value support among other things

This commit is contained in:
Ryan Cramer
2020-05-01 16:39:34 -04:00
parent b5c4980b3b
commit 99f5d59ce4
3 changed files with 237 additions and 39 deletions

View File

@@ -10,7 +10,7 @@
* of what other methods/objects have done to it. It also means being able
* to build a complex query without worrying about correct syntax placement.
*
* ProcessWire 3.x, Copyright 2016 by Ryan Cramer
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
* https://processwire.com
*
* This file is licensed under the MIT license
@@ -18,7 +18,7 @@
*
* @property array $where
* @property array $bindValues
* @property array $bindIndex
* @property array $bindIndex
*
*/
abstract class DatabaseQuery extends WireData {
@@ -32,44 +32,180 @@ abstract class DatabaseQuery extends WireData {
protected $bindValues = array();
/**
* Index of bound values per originating method
* Bound parameter types of name => \PDO::PARAM_* type constant
*
* Populated only when a type is provided to the bindValue() call
*
* @var array
*
*/
protected $bindTypes = array();
/**
* Index of bound values per originating method (deprecated)
*
* Indexed by originating method, with values as the bound parameter names as in $bindValues.
* This is populated by the setupBindValues() method
* This is populated by the setupBindValues() method. The purpose of this is for one part of
* one query is imported to another, the appropriate bound values are also imported. This is
* deprecated because it does not work if values are bound independently of `$q->where()`, etc.
*
* @var array
*
*/
protected $bindIndex = array();
/**
* @var int
*
*/
protected $instanceNum = 0;
/**
* @var int
*
*/
static $uniq = 0;
/**
* @var int
*
*/
static $numInstances = 0;
/**
* Construct
*
*/
public function __construct() {
self::$numInstances++;
$this->instanceNum = self::$numInstances;
parent::__construct();
}
/**
* Bind a parameter value
*
* @param string $key Parameter name
* @param mixed $value Parameter value
* @param null|int|string Optionally specify value type: string, int, bool, null or PDO::PARAM_* constant.
* @return $this
*
*/
public function bindValue($key, $value) {
public function bindValue($key, $value, $type = null) {
if(strpos($key, ':') !== 0) $key = ":$key";
$this->bindValues[$key] = $value;
if($type !== null) $this->setBindType($key, $type);
return $this;
}
/**
* Bind multiple parameter values
*
* #pw-internal
*
* @param array $bindValues
* @return $this
* @since 3.0.156
*
*/
public function bindValues(array $bindValues) {
foreach($bindValues as $key => $value) {
$this->bindValue($key, $value);
}
return $this;
}
/**
* Get bound parameter values, optionally for a specific method call
* Set bind type
*
* @param string $method
* @return array
* @param string $key
* @param int|string $type
*
*/
public function getBindValues($method = '') {
if(empty($method)) return $this->bindValues;
if(!isset($this->bindIndex[$method])) return array();
$names = $this->bindIndex[$method];
$values = array();
foreach($names as $name) {
$values[$name] = $this->bindValues[$name];
protected function setBindType($key, $type) {
if(is_int($type) || ctype_digit("$type")) {
$this->bindTypes[$key] = (int) $type;
}
switch(strtolower(substr($type, 0, 3))) {
case 'str': $type = \PDO::PARAM_STR; break;
case 'int': $type = \PDO::PARAM_INT; break;
case 'boo': $type = \PDO::PARAM_BOOL; break;
case 'nul': $type = \PDO::PARAM_NULL; break;
default: $type = null;
}
if($type !== null) $this->bindTypes[$key] = $type;
}
/**
* Get a unique key to use for bind value
*
* @param string $key Preferred bind key or prefix, or omit to auto-generate
* @return string Returns bind key/name in format ":name" (with leading colon)
* @since 3.0.156
*
*/
public function getUniqueBindKey($key = '') {
$key = ltrim($key, ':');
if(!ctype_alnum(str_replace('_', '', $key))) {
$key = $this->wire('database')->escapeCol($key);
}
if(!strlen($key) || ctype_digit($key[0])) $key = "pwbk$key";
do {
$name = ':' . $key . '_' . $this->instanceNum . '_' . (++self::$uniq);
} while(isset($this->bindValues[$name]));
return $name;
}
/**
* Get bound parameter values (or populate to given query)
*
* - If given a string for $options argument it assumed to be the `method` option.
* - If given a \PDOStatement or DatabaseQuery, it is assumed to be the `query` option.
*
* Note: The $options argument was added in 3.0.156, prior to this it was a $method argument,
* which is the same as the `method` option (string).
*
* @param string|\PDOStatement|DatabaseQuery|array $options Optionally specify an option:
* - `method` (string): Get bind values just for this DatabaseQuery method name (default='') deprecated
* - `query` (\PDOStatement|DatabaseQuery): Populate bind values to this query object (default=null)
* @return array Associative array in format [ ":column" => "value" ] where each "value" is int, string or NULL.
*
*/
public function getBindValues($options = array()) {
$defaults = array(
'method' => is_string($options) ? $options : '',
'query' => is_object($options) ? $options : null,
);
$options = is_array($options) ? array_merge($defaults, $options) : $defaults;
$query = $options['query'];
$method = $options['method'];
if($method) {
if(!isset($this->bindIndex[$method])) return array();
$names = isset($this->bindIndex[$method]) ? $this->bindIndex[$method] : array();
$values = array();
foreach($names as $name) {
$name = ':' . ltrim($name, ':');
$values[$name] = $this->bindValues[$name];
}
} else {
$values = $this->bindValues;
}
if($query && $query instanceof \PDOStatement) {
foreach($values as $k => $v) {
$type = $this->pdoParamType($v);
$query->bindValue($k, $v, $type);
}
} else if($query && $query instanceof DatabaseQuery && $query !== $this) {
$query->bindValues($values);
}
return $values;
}
@@ -82,7 +218,7 @@ abstract class DatabaseQuery extends WireData {
*
* To bind parameters, specify associative array as second argument:
*
* $query->where("name=:name", array(':name' => $page->name));
* $query->where("name=:name", [ ':name' => $page->name ]);
*
* To import query/method and bound values from another DatabaseQuery:
*
@@ -96,12 +232,18 @@ abstract class DatabaseQuery extends WireData {
*
*/
public function __call($method, $args) {
if(!$this->has($method)) return parent::__call($method, $args);
if(empty($args[0])) return $this;
$curValue = $this->get($method);
$value = $args[0];
if(!is_array($curValue)) $curValue = array();
if(empty($value)) return $this;
if(is_object($value) && $value instanceof DatabaseQuery) {
// if we've been given another DatabaseQuery, load from it's $method
// if we've been given another DatabaseQuery, load from its $method
$query = $value;
$value = $query->$method;
if(!is_array($value) || !count($value)) return $this; // nothing to import
@@ -109,67 +251,92 @@ abstract class DatabaseQuery extends WireData {
} else {
$params = isset($args[1]) && is_array($args[1]) ? $args[1] : null;
}
if(!empty($params)) {
$value = $this->setupBindValues($value, $params, $method);
$this->methodBindValues($value, $params, $method);
}
if(is_array($value)) {
$curValue = array_merge($curValue, $value);
} else {
$curValue[] = trim($value, ", ");
}
$this->set($method, $curValue);
return $this;
}
/**
* Setup bound parameters for the given query, returning an updated $value if any renames needed to be made
* Setup bound parameters for the given SQL provided to method call
*
* This is only used when params are provided as part of a method call like:
* $query->where("foo=:bar", [ ":bar" => "baz" ]);
*
* #pw-internal
*
* @param string|array $sql
* @param array $params
* @param array $values Associative array of bound values
* @param string $method Method name that the bound values are for
* @return string
*
*/
public function setupBindValues($sql, array $params, $method) {
foreach($params as $name => $value) {
if(strpos($name, ':') !== 0) $name = ":$name";
$newName = $name;
$n = 0;
while(isset($this->bindValues[$newName])) {
$newName = $name . (++$n);
}
if($n) {
protected function methodBindValues(&$sql, array $values, $method) {
foreach($values as $name => $value) {
$name = ':' . ltrim($name, ':');
if(isset($this->bindValues[$name])) {
// bind key already in use, use a different unique one instead
$newName = $this->getUniqueBindKey($name);
if(is_array($sql)) {
foreach($sql as $k => $v) {
if(strpos($v, $name) === false) continue;
$sql[$k] = preg_replace('/' . $name . '\b/', $newName, $v);
}
} else {
} else if(strpos($sql, $name) !== false) {
$sql = preg_replace('/' . $name . '\b/', $newName, $sql);
}
$name = $newName;
}
$this->bindValue($name, $value);
if(!isset($this->bindIndex[$method])) $this->bindIndex[$method] = array();
$this->bindIndex[$method][] = $name;
}
return $sql;
}
/**
* @param string $key
* @param mixed $value
*
*/
public function __set($key, $value) {
if(is_array($this->$key)) $this->__call($key, array($value));
}
/**
* @param string $key
* @return array|mixed|null
*
*/
public function __get($key) {
if($key == 'query') return $this->getQuery();
else if($key == 'bindValues') return $this->bindValues;
else if($key == 'bindIndex') return $this->bindIndex;
else return parent::__get($key);
if($key === 'query') {
return $this->getQuery();
} else if($key === 'bindValues') {
return $this->bindValues;
} else if($key === 'bindIndex') {
return $this->bindIndex;
}
return parent::__get($key);
}
/**
* Merge the contents of current query with another (experimental/incomplete)
*
* #pw-internal
*
* @internal
* @param DatabaseQuery $query
* @return $this
@@ -209,16 +376,35 @@ abstract class DatabaseQuery extends WireData {
public function prepare() {
$query = $this->wire('database')->prepare($this->getQuery());
foreach($this->bindValues as $key => $value) {
$query->bindValue($key, $value);
$type = isset($this->bindTypes[$key]) ? $this->bindTypes[$key] : $this->pdoParamType($value);
$query->bindValue($key, $value, $type);
}
return $query;
}
/**
* Get the PDO::PARAM_* type for given value
*
* @param string|int|null $value
* @return int
*
*/
protected function pdoParamType($value) {
if(is_int($value)) {
$type = \PDO::PARAM_INT;
} else if($value === null) {
$type = \PDO::PARAM_NULL;
} else {
$type = \PDO::PARAM_STR;
}
return $type;
}
/**
* Execute the query with the current database handle
*
* @return \PDOStatement
* @throws WireException|\Exception|\PDOException
* @throws WireDatabaseQueryException|\PDOException
*
*/
public function execute() {
@@ -230,7 +416,7 @@ abstract class DatabaseQuery extends WireData {
$msg = $e->getMessage();
if(stripos($msg, 'MySQL server has gone away') !== false) $database->closeConnection();
if($this->wire('config')->allowExceptions) throw $e; // throw original
throw new WireException($msg); // throw WireException
throw new WireDatabaseQueryException($msg, $e->getCode(), $e);
}
return $query;
}

View File

@@ -51,6 +51,7 @@ class DatabaseQuerySelect extends DatabaseQuery {
*
*/
public function __construct() {
parent::__construct();
$this->set('select', array());
$this->set('join', array());
$this->set('from', array());

View File

@@ -121,6 +121,17 @@ class Wire404Exception extends WireException {
*/
class WireDatabaseException extends WireException {}
/**
* Thrown by DatabaseQuery classes on query exception
*
* May have \PDOException populated with call to its getPrevious(); method,
* in which can it also has same getCode() and getMessage() as \PDOException.
*
* @since 3.0.156
*
*/
class WireDatabaseQueryException extends WireException {}
/**
* Thrown when cross site request forgery detected by SessionCSRF::validate()
*