From 99f5d59ce4a52f8b03c4da31d5aa76ec02438aea Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Fri, 1 May 2020 16:39:34 -0400 Subject: [PATCH] Some updates to base DatabaseQuery class to improve bound value support among other things --- wire/core/DatabaseQuery.php | 264 +++++++++++++++++++++++++----- wire/core/DatabaseQuerySelect.php | 1 + wire/core/Exceptions.php | 11 ++ 3 files changed, 237 insertions(+), 39 deletions(-) diff --git a/wire/core/DatabaseQuery.php b/wire/core/DatabaseQuery.php index 8505fb5a..ca5ed6cd 100644 --- a/wire/core/DatabaseQuery.php +++ b/wire/core/DatabaseQuery.php @@ -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; } diff --git a/wire/core/DatabaseQuerySelect.php b/wire/core/DatabaseQuerySelect.php index 8eff81bd..b505a369 100644 --- a/wire/core/DatabaseQuerySelect.php +++ b/wire/core/DatabaseQuerySelect.php @@ -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()); diff --git a/wire/core/Exceptions.php b/wire/core/Exceptions.php index 2651971a..910f1932 100644 --- a/wire/core/Exceptions.php +++ b/wire/core/Exceptions.php @@ -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() *