diff --git a/wire/core/DatabaseQuery.php b/wire/core/DatabaseQuery.php index ca5ed6cd..482e1f1e 100644 --- a/wire/core/DatabaseQuery.php +++ b/wire/core/DatabaseQuery.php @@ -18,7 +18,12 @@ * * @property array $where * @property array $bindValues - * @property array $bindIndex + * @property array $bindKeys + * @property array $bindOptions + * @property string $query + * @property string $sql + * + * @method $this where($sql, array $params = array()) * */ abstract class DatabaseQuery extends WireData { @@ -40,20 +45,21 @@ abstract class DatabaseQuery extends WireData { * */ protected $bindTypes = array(); + + /** + * @var array + * + */ + protected $bindKeys = array(); /** - * Index of bound values per originating method (deprecated) + * Method names for building DB queries * - * Indexed by originating method, with values as the bound parameter names as in $bindValues. - * 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 + * @var array * */ - protected $bindIndex = array(); - + protected $queryMethods = array(); + /** * @var int * @@ -64,8 +70,17 @@ abstract class DatabaseQuery extends WireData { * @var int * */ - static $uniq = 0; + protected $keyNum = 0; + /** + * @var array + * + */ + protected $bindOptions = array( + 'prefix' => 'pw', // prefix for auto-generated keys + 'global' => false // globally unique among all bind keys in all instances? + ); + /** * @var int * @@ -79,9 +94,46 @@ abstract class DatabaseQuery extends WireData { public function __construct() { self::$numInstances++; $this->instanceNum = self::$numInstances; + $this->addQueryMethod('where', " \nWHERE ", " \nAND "); parent::__construct(); } + /** + * Add a query method + * + * #pw-internal + * + * @param string $name + * @param string $prepend Prepend first statement with this + * @param string $split Split multiple statements with this + * @param string $append Append this to last statement (if needed) + * @since 3.0.157 + * + */ + protected function addQueryMethod($name, $prepend = '', $split = '', $append = '') { + $this->queryMethods[$name] = array($prepend, $split, $append); + $this->set($name, array()); + } + + /** + * Get or set a bind option + * + * @param string|bool $optionName One of 'prefix' or 'global', boolean true to get/set all + * @param null|int|string|array $optionValue Omit when getting, Specify option value to set, or array when setting all + * @return string|int|array + * @since 3.0.157 + * + */ + public function bindOption($optionName, $optionValue = null) { + if($optionName === true) { + if(is_array($optionValue)) $this->bindOptions = array_merge($this->bindOptions, $optionValue); + return $this->bindOptions; + } else if($optionValue !== null) { + $this->bindOptions[$optionName] = $optionValue; + } + return isset($this->bindOptions[$optionName]) ? $this->bindOptions[$optionName] : null; + } + /** * Bind a parameter value * @@ -94,35 +146,57 @@ abstract class DatabaseQuery extends WireData { public function bindValue($key, $value, $type = null) { if(strpos($key, ':') !== 0) $key = ":$key"; $this->bindValues[$key] = $value; + $this->bindKeys[$key] = $key; if($type !== null) $this->setBindType($key, $type); return $this; } + + /** + * Bind value and get unique key that refers to it in one step + * + * @param string|int|float $value + * @param null|int|string $type + * @return string + * @since 3.0.157 + * + */ + public function bindValueGetKey($value, $type = null) { + $key = $this->getUniqueBindKey(array('value' => $value)); + $this->bindValue($key, $value, $type); + return $key; + } /** - * Bind multiple parameter values + * Get or set multiple parameter values * * #pw-internal * - * @param array $bindValues - * @return $this + * @param array|null $bindValues Omit to get or specify array to set + * @return $this|array Returns array when getting or $this when setting * @since 3.0.156 * */ - public function bindValues(array $bindValues) { - foreach($bindValues as $key => $value) { - $this->bindValue($key, $value); + public function bindValues($bindValues = null) { + if(is_array($bindValues)) { + foreach($bindValues as $key => $value) { + $this->bindValue($key, $value); + } + return $this; + } else { + return $this->bindValues; } - return $this; } /** * Set bind type * + * #pw-internal + * * @param string $key * @param int|string $type * */ - protected function setBindType($key, $type) { + public function setBindType($key, $type) { if(is_int($type) || ctype_digit("$type")) { $this->bindTypes[$key] = (int) $type; @@ -139,92 +213,214 @@ abstract class DatabaseQuery extends WireData { if($type !== null) $this->bindTypes[$key] = $type; } + /** + * Get or set all bind types + * + * #pw-internal + * + * @param array|null $bindTypes Omit to get, or specify associative array of [ ":bindKey" => int ] to set + * @return array|$this Returns array when getting or $this when setting + * @since 3.0.157 + * + */ + public function bindTypes($bindTypes = null) { + if(is_array($bindTypes)) { + $this->bindTypes = array_merge($this->bindTypes, $bindTypes); // set + return $this; + } + return $this->bindTypes; // get + } + /** * Get a unique key to use for bind value * - * @param string $key Preferred bind key or prefix, or omit to auto-generate + * Note if you given a `key` option, it will only be used if it is determined unique, + * otherwise it’ll auto-generate one. When using your specified key, it is the only + * option that applies, unless it is not unique and the method has to auto-generate one. + * + * @param array $options + * - `key` (string): Preferred bind key, or omit (blank) to auto-generate (digit only keys not accepted) + * - `value` (string|int): Value to use as part of the generated key + * - `prefix` (string): Prefix to override default + * - `global` (bool): Require globally unique among all instances? * @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); + public function getUniqueBindKey(array $options = array()) { + + static $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + if(empty($options['key'])) { + // auto-generate key + $key = ':'; + $prefix = (isset($options['prefix']) ? $options['prefix'] : $this->bindOptions['prefix']); + $value = isset($options['value']) ? $options['value'] : null; + $global = isset($options['global']) ? $options['global'] : $this->bindOptions['global']; + + if($global) $key .= $prefix . $this->instanceNum; + + if($value !== null) { + if(is_int($value)) { + $key .= "int"; + } else if(is_string($value)) { + $key .= "str"; + } else if(is_array($value)) { + $key .= "arr"; + } else { + $key .= "oth"; + } + } else if($prefix && !$global) { + $key .= $prefix; + } + + $k = $key; + $n = 0; + while(isset($this->bindKeys[$key])) { + $key = $k . (isset($alpha[$n]) ? $alpha[$n] : $n); + $n++; + } + + } else { + // provided key, make sure it is valid and unique + $key = ltrim($options['key'], ':'); + if(!ctype_alnum(str_replace('_', '', $key))) $key = $this->wire('database')->escapeCol($key); + if(empty($key) || ctype_digit($key) || isset($this->bindKeys[":$key"])) { + // if key is not valid, then auto-generate one instead + unset($options['key']); + $key = $this->getUniqueBindKey($options); + } else { + $key = ":$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; + + $this->bindKeys[$key] = $key; + + return $key; } /** - * Get bound parameter values (or populate to given query) + * Get bind values, with options * - * - 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. + * - When copying, you may prefer to use the copyBindValuesTo() method instead (more readable). * * 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). + * which was never used so has been removed. * * @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. + * - `query` (\PDOStatement|DatabaseQuery): Copy bind values to this query object (default=null) + * - `count` (bool): Get a count of values rather than array of values (default=false) 3.0.157+ + * - `inSQL` (string): Only get bind values referenced in this given SQL statement + * @return array|int Returns one of the following: + * - Associative array in format [ ":column" => "value" ] where each "value" is int, string or NULL. + * - if `count` option specified as true then it returns a count of values instead. * */ public function getBindValues($options = array()) { $defaults = array( - 'method' => is_string($options) ? $options : '', 'query' => is_object($options) ? $options : null, + 'count' => false, + 'inSQL' => '', ); $options = is_array($options) ? array_merge($defaults, $options) : $defaults; $query = $options['query']; - $method = $options['method']; + $bindValues = $this->bindValues; - 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]; + if(!empty($options['inSQL'])) { + foreach(array_keys($bindValues) as $bindKey) { + if(strpos($options['inSQL'], $bindKey) === false) unset($bindValues[$bindKey]); + } + } + + if(is_object($query)) { + if($query instanceof \PDOStatement) { + foreach($bindValues as $k => $v) { + $type = isset($this->bindTypes[$k]) ? $this->bindTypes[$k] : $this->pdoParamType($v); + $query->bindValue($k, $v, $type); + } + } else if($query instanceof DatabaseQuery && $query !== $this) { + $query->bindValues($bindValues); + $query->bindTypes($this->bindTypes); } - } 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; + return $options['count'] ? count($bindValues) : $bindValues; } + /** + * Copy bind values from this query to another given DatabaseQuery or \PDOStatement + * + * This is a more readable interface to the getBindValues() method and does the same + * thing as passing a DatabaseQuery or PDOStatement to the getBindValues() method. + * + * @param DatabaseQuery|\PDOStatement $query + * @param array $options Additional options + * - `inSQL` (string): Only copy bind values that are referenced in given SQL string + * @return int Number of bind values that were copied + * @since 3.0.157 + * + */ + public function copyBindValuesTo($query, array $options = array()) { + $options['query'] = $query; + if(!isset($options['count'])) $options['count'] = true; + return $this->getBindValues($options); + } + + /** + * Copy queries from this DatabaseQuery to another DatabaseQuery + * + * If you want to copy bind values you should also call copyBindValuesTo($query) afterwards. + * + * @param DatabaseQuery $query Query to copy data to + * @param array $methods Optionally specify the names of methods to copy, otherwise all are copied + * @return int Total items copied + * @since 3.0.157 + * + */ + public function copyTo(DatabaseQuery $query, array $methods = array()) { + + $numCopied = 0; + if($query === $this) return 0; + if(!count($methods)) $methods = array_keys($this->queryMethods); + + foreach($methods as $method) { + if($method === 'bindValues') continue; + $fromValues = $this->$method; // array + if(!is_array($fromValues)) continue; // nothing to import + $toValues = $query->$method; + if(!is_array($toValues)) continue; // query does not have this method + $query->set($method, array_merge($toValues, $fromValues)); + $numCopied += count($fromValues); + } + + return $numCopied; + } + /** * Enables calling the various parts of a query as functions for a fluent interface. * * Examples (all in context of DatabaseQuerySelect): + * ~~~~~ + * $query->select("id")->from("mytable")->orderby("name"); + * ~~~~~ + * To bind one or more named parameters, specify associative array as second argument: + * ~~~~~ + * $query->where("name=:name", [ ':name' => $page->name ]); + * ~~~~~ + * To bind one or more implied parameters, use question marks and specify regular array: + * ~~~~~ + * $query->where("name=?, id=?", [ $page->name, $page->id ]); + * ~~~~~ + * When there is only one implied parameter, specifying an array is optional: + * ~~~~~ + * $query->where("name=?", $page->name); + * ~~~~~ * - * $query->select("id")->from("mytable")->orderby("name"); - * - * To bind parameters, specify associative array as second argument: - * - * $query->where("name=:name", [ ':name' => $page->name ]); - * - * To import query/method and bound values from another DatabaseQuery: - * - * $query->select($anotherQuery); - * - * The "select" may be any method supported by the class. + * The "select" or "where" methods above may be any method supported by the class. + * Implied parameters (using "?") was added in 3.0.157. * * @param string $method * @param array $args @@ -233,27 +429,30 @@ 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(!$this->has($method)) return parent::__call($method, $args); + if(!isset($this->queryMethods[$method])) return parent::__call($method, $args); + if(!count($args)) return $this; + $curValue = $this->get($method); if(!is_array($curValue)) $curValue = array(); - if(empty($value)) return $this; + $value = $args[0]; if(is_object($value) && $value instanceof DatabaseQuery) { // if we've been given another DatabaseQuery, load from its $method + // note that if using bindValues you should also copy them separately + // behavior deprecated in 3.l0.157+, please use the copyTo() method instead + /** @var DatabaseQuery $query */ $query = $value; - $value = $query->$method; + $value = $query->$method; // array if(!is_array($value) || !count($value)) return $this; // nothing to import - $params = $query->getBindValues($method); - } else { - $params = isset($args[1]) && is_array($args[1]) ? $args[1] : null; - } - - if(!empty($params)) { - $this->methodBindValues($value, $params, $method); + + } else if(is_string($value)) { + // value is SQL string, number or array + $params = isset($args[1]) ? $args[1] : null; + if($params !== null && !is_array($params)) $params = array($params); + if(is_array($params) && count($params)) $value = $this->methodBindValues($value, $params); + + } else if(!empty($args[1])) { + throw new WireException("Argument error in $this::$method('string required here when using bind values', [ bind values ])"); } if(is_array($value)) { @@ -268,41 +467,63 @@ abstract class DatabaseQuery extends WireData { } /** - * Setup bound parameters for the given SQL provided to method call + * Setup bind params 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" ]); + * ~~~~~ + * $query->where("foo=:bar", [ ":bar" => "baz" ]); // named + * $query->where("foo=?", [ "baz" ]); // implied + * ~~~~~ * * #pw-internal * - * @param string|array $sql - * @param array $values Associative array of bound values - * @param string $method Method name that the bound values are for + * @param string $sql + * @param array $values Bind values + * @return string + * @throws WireException * */ - protected function methodBindValues(&$sql, array $values, $method) { + protected function methodBindValues($sql, array $values) { + + $numImplied = 0; + $numNamed = 0; + $_sql = $sql; + + if(!is_string($sql)) { + throw new WireException('methodBindValues requires a string for $sql argument'); + } 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 if(strpos($sql, $name) !== false) { - $sql = preg_replace('/' . $name . '\b/', $newName, $sql); + if(is_int($name)) { + // implied parameter + $numImplied++; + if(strpos($sql, '?') === false) { + throw new WireException("No place for given param $name in: $_sql"); + } + do { + $name = $this->getUniqueBindKey(array('value' => $value)); + } while(strpos($sql, $name) !== false); // highly unlikely, but just in case + list($a, $b) = explode('?', $sql, 2); + $sql = $a . $name . $b; + + } else { + // named parameter + $numNamed++; + if(strpos($name, ':') !== 0) $name = ":$name"; + if(strpos($sql, $name) === false) { + throw new WireException("Param $name not found in: $_sql"); } - $name = $newName; } - $this->bindValue($name, $value); - if(!isset($this->bindIndex[$method])) $this->bindIndex[$method] = array(); - $this->bindIndex[$method][] = $name; } + + if($numImplied && strpos($sql, '?') !== false) { + throw new WireException("Missing implied “?” param in: $_sql"); + } else if($numImplied && $numNamed) { + throw new WireException("You may not mix named and implied params in: $_sql"); + } + + return $sql; } /** @@ -321,13 +542,15 @@ abstract class DatabaseQuery extends WireData { */ public function __get($key) { - if($key === 'query') { + if($key === 'query' || $key === 'sql') { return $this->getQuery(); } else if($key === 'bindValues') { return $this->bindValues; - } else if($key === 'bindIndex') { - return $this->bindIndex; - } + } else if($key === 'bindOptions') { + return $this->bindOptions; + } else if($key === 'bindKeys') { + return $this->bindKeys; + } return parent::__get($key); } @@ -340,6 +563,7 @@ abstract class DatabaseQuery extends WireData { * @internal * @param DatabaseQuery $query * @return $this + * @deprecated * */ public function merge(DatabaseQuery $query) { @@ -356,16 +580,50 @@ abstract class DatabaseQuery extends WireData { abstract public function getQuery(); /** - * Get the WHERE portion of the query + * Return generated SQL for entire query or specific method + * + * @param string $method Optionally specify method name to get SQL for + * @return string + * @since 3.0.157 * */ + public function getSQL($method = '') { + return $method ? $this->getQueryMethod($method) : $this->getQuery(); + } + + /** + * Return the generated SQL for specific query method + * + * @param string $method Specify method name to get SQL for + * @return string + * @since 3.0.157 + * + */ + public function getQueryMethod($method) { + + if(!$method) return $this->getQuery(); + if(!isset($this->queryMethods[$method])) return ''; + $methodName = 'getQuery' . ucfirst($method); + if(method_exists($this, $methodName)) return $this->$methodName(); + + list($prepend, $split, $append) = $this->queryMethods[$method]; + $values = $this->$method; + if(!is_array($values) || !count($values)) return ''; + $sql = $prepend . implode($split, $values) . $append; + + return $sql; + } + + /** + * Get the WHERE portion of the query + * protected function getQueryWhere() { - if(!count($this->where)) return ''; $where = $this->where; - $sql = "\nWHERE " . array_shift($where) . " "; - foreach($where as $s) $sql .= "\nAND $s "; + if(!count($where)) return ''; + $sql = "\nWHERE " . implode(" \nAND ", $where) . " "; return $sql; } + */ /** * Prepare and return a PDOStatement diff --git a/wire/core/DatabaseQuerySelect.php b/wire/core/DatabaseQuerySelect.php index b505a369..416e0e92 100644 --- a/wire/core/DatabaseQuerySelect.php +++ b/wire/core/DatabaseQuerySelect.php @@ -46,20 +46,27 @@ */ class DatabaseQuerySelect extends DatabaseQuery { + /** + * DB cache setting from $config + * + * @var null + * + */ + static $dbCache = null; + /** * Setup the components of a SELECT query * */ public function __construct() { parent::__construct(); - $this->set('select', array()); - $this->set('join', array()); - $this->set('from', array()); - $this->set('leftjoin', array()); - $this->set('where', array()); - $this->set('orderby', array()); - $this->set('groupby', array()); - $this->set('limit', array()); + $this->addQueryMethod('select', 'SELECT ', ', '); + $this->addQueryMethod('from', " \nFROM `", '`,`', '` '); + $this->addQueryMethod('join', " \nJOIN ", " \nJOIN "); + $this->addQueryMethod('leftjoin', " \nLEFT JOIN ", " \nLEFT JOIN "); + $this->addQueryMethod('orderby', " \nORDER BY ", ","); + $this->addQueryMethod('groupby', " \nGROUP BY ", ','); + $this->addQueryMethod('limit', " \nLIMIT ", ','); $this->set('comment', ''); } @@ -69,15 +76,16 @@ class DatabaseQuerySelect extends DatabaseQuery { */ public function getQuery() { - $sql = - $this->getQuerySelect() . - $this->getQueryFrom() . - $this->getQueryJoin($this->join, "JOIN") . - $this->getQueryJoin($this->leftjoin, "LEFT JOIN") . - $this->getQueryWhere() . - $this->getQueryGroupby() . - $this->getQueryOrderby() . - $this->getQueryLimit(); + $sql = trim( + $this->getQueryMethod('select') . + $this->getQueryMethod('from') . + $this->getQueryMethod('join') . + $this->getQueryMethod('leftjoin') . + $this->getQueryMethod('where') . + $this->getQueryMethod('groupby') . + $this->getQueryMethod('orderby') . + $this->getQueryMethod('limit') + ) . ' '; if($this->get('comment') && $this->wire('config')->debug) { // NOTE: PDO thinks ? and :str param identifiers in /* comments */ are real params @@ -90,7 +98,7 @@ class DatabaseQuerySelect extends DatabaseQuery { } /** - * Add an 'order by' element to the query + * Add an ORDER BY section to the query * * @param string|array $value * @param bool $prepend Should the value be prepended onto the existing value? default is to append rather than prepend. @@ -125,10 +133,20 @@ class DatabaseQuerySelect extends DatabaseQuery { return $this; } + /** + * Get SELECT portion of SQL + * + * @return string + * + */ protected function getQuerySelect() { + + if(self::$dbCache === null) { + self::$dbCache = $this->wire('config')->dbCache === false ? false : true; + } - $sql = ''; $select = $this->select; + $sql = ''; // ensure that an SQL_CALC_FOUND_ROWS request comes first while(($key = array_search("SQL_CALC_FOUND_ROWS", $select)) !== false) { @@ -136,36 +154,17 @@ class DatabaseQuerySelect extends DatabaseQuery { unset($select[$key]); } if(!$sql) $sql = "SELECT "; - - // $config->dbCache option for debugging purposes - if($this->wire('config')->dbCache === false) $sql .= "SQL_NO_CACHE "; - - foreach($select as $s) $sql .= "$s,"; - $sql = rtrim($sql, ",") . " "; - return $sql; - } - - protected function getQueryFrom() { - $sql = "\nFROM "; - foreach($this->from as $s) $sql .= "`$s`,"; - $sql = rtrim($sql, ",") . " "; - return $sql; - } - - protected function getQueryJoin(array $join, $type) { - $sql = ''; - foreach($join as $s) $sql .= "\n$type $s "; - return $sql; - } - - protected function getQueryOrderby() { - if(!count($this->orderby)) return ''; - $sql = "\nORDER BY "; - foreach($this->orderby as $s) $sql .= "$s,"; - $sql = rtrim($sql, ",") . " "; - return $sql; + if(self::$dbCache === false) $sql .= "SQL_NO_CACHE "; + + return $sql . implode(',', $select) . ' '; } + /** + * Get GROUP BY section of SQL + * + * @return string + * + */ protected function getQueryGroupby() { if(!count($this->groupby)) return ''; $sql = "\nGROUP BY "; @@ -186,23 +185,31 @@ class DatabaseQuerySelect extends DatabaseQuery { foreach($having as $n => $h) { if($n > 0) $sql .= " AND "; $sql .= $h; - } } - $sql = rtrim($sql, ",") . " "; - - - return $sql; + return rtrim($sql, ",") . " "; } + /** + * Get LIMIT section of SQL + * + * @return string + * + */ protected function getQueryLimit() { if(!count($this->limit)) return ''; $limit = $this->limit; - $sql = "\nLIMIT " . reset($limit) . " "; - return $sql; + $limit = reset($limit); + if(strpos($limit, ',') !== false) { + list($start, $limit) = explode(',', $limit); + $start = (int) trim($start); + $limit = (int) trim($limit); + $limit = "$start,$limit"; + } else { + $limit = (int) $limit; + } + return "\nLIMIT $limit "; } - - } diff --git a/wire/core/DatabaseQuerySelectFulltext.php b/wire/core/DatabaseQuerySelectFulltext.php index 8ea1cc11..0910f08b 100644 --- a/wire/core/DatabaseQuerySelectFulltext.php +++ b/wire/core/DatabaseQuerySelectFulltext.php @@ -20,52 +20,153 @@ * This file is licensed under the MIT license * https://processwire.com/about/license/mit/ * - * ProcessWire 3.x, Copyright 2019 by Ryan Cramer + * ProcessWire 3.x, Copyright 2020 by Ryan Cramer * https://processwire.com + * + * @property-read $tableField * + * * */ class DatabaseQuerySelectFulltext extends Wire { /** * Max length that we allow for a query - * + * */ const maxQueryValueLength = 500; /** * @var DatabaseQuerySelect - * + * */ protected $query; /** - * Keep track of field names used for scores so that the same one isn't ever used more than once - * + * @var string + * + */ + protected $tableName = ''; + + /** + * @var $fieldName + * + */ + protected $fieldName = ''; + + /** + * @var string + * + */ + protected $operator = ''; + + /** + * @var string + * + */ + protected $method = ''; + + /** + * Method names to operators they handle + * * @var array - * + * + */ + protected $methodOperators = array( + 'matchEquals' => array('=', '!=', '>', '<', '>=', '<='), + 'matchContains' => array('*='), + 'matchWords' => array('~=', '!~='), + 'matchLIKE' => array('%='), + 'matchStart' => array('^=', '%^='), + 'matchEnd' => array('$=', '%$='), + ); + + /** + * Keep track of field names used for scores so that the same one isn't ever used more than once + * + * @var array + * */ static $scoreFields = array(); /** * Construct - * + * * @param DatabaseQuerySelect $query - * + * */ public function __construct(DatabaseQuerySelect $query) { - $this->query = $query; + $this->query = $query; + } + + /** + * @param string $key + * + * @return mixed|string + * + */ + public function __get($key) { + if($key === 'tableField') return $this->tableField(); + return parent::__get($key); + } + + /** + * Get the query that was provided to the constructor + * + * @return DatabaseQuerySelect + * + */ + public function getQuery() { + return $this->query; + } + + /** + * Get 'tableName.fieldName' string + * + * @return string + * + */ + protected function tableField() { + return "$this->tableName.$this->fieldName"; } /** * Escape string for use in a MySQL LIKE * + * When applicable, $database->escapeStr() should be applied before this. + * * @param string $str * @return string * */ protected function escapeLIKE($str) { - return preg_replace('/([%_])/', '\\\$1', $str); + return str_replace(array('%', '_'), array('\\%', '\\_'), $str); + } + + /** + * Additional escape for use in a MySQL AGAINST + * + * When applicable, $database->escapeStr() must also be applied (before or after). + * + * @param string $str + * @return string + * + */ + protected function escapeAGAINST($str) { + return str_replace(array('@', '+', '-', '*', '~', '<', '>', '(', ')', ':', '"', '&', '|', '=', '.'), ' ', $str); + } + + /** + * @param string $value + * @return string + * + */ + protected function value($value) { + $maxLength = self::maxQueryValueLength; + $value = trim($value); + if(strlen($value) < $maxLength && strpos($value, "\n") === false) return $value; + $value = $this->sanitizer->trunc($value, $maxLength); + return $value; } /** @@ -81,72 +182,27 @@ class DatabaseQuerySelectFulltext extends Wire { */ public function match($tableName, $fieldName, $operator, $value) { - if(is_array($value)) return $this->matchArrayValue($tableName, $fieldName, $operator, $value); - - $database = $this->wire('database'); - $query = $this->query; - $value = substr(trim($value), 0, self::maxQueryValueLength); - $tableName = $database->escapeTable($tableName); - $fieldName = $database->escapeCol($fieldName); - $tableField = "$tableName.$fieldName"; - - switch($operator) { - - case '=': - case '!=': - case '>': - case '<': - case '<=': - case '>=': - $v = $database->escapeStr($value); - $query->where("$tableField$operator'$v'"); - // @todo, bound values can be used instead for many cases, update to use them like this: - // $query->where("$tableField$operator:value", array(':value' => $value)); - break; - - case '*=': - $this->matchContains($tableName, $fieldName, $operator, $value); - break; - - case '~=': - case '!~=': - $words = preg_split('/[-\s,]/', $value, -1, PREG_SPLIT_NO_EMPTY); - foreach($words as $word) { - $len = function_exists('mb_strlen') ? mb_strlen($word) : strlen($word); - if(DatabaseStopwords::has($word) || $len < $database->getVariable('ft_min_word_len')) { - $this->matchWordLIKE($tableName, $fieldName, $operator, $word); - } else { - $this->matchContains($tableName, $fieldName, $operator, $word); - } - } - if(!count($words)) $query->where("1>2"); // force it not to match if no words - break; - - case '%=': - $v = $database->escapeStr($value); - $v = $this->escapeLIKE($v); - $query->where("$tableField LIKE '%$v%'"); // SLOW, but assumed - break; - - case '^=': - case '%^=': // match at start using only LIKE (no index) - $v = $database->escapeStr($value); - $v = $this->escapeLIKE($v); - $query->where("$tableField LIKE '$v%'"); - break; - - case '$=': - case '%$=': // RCD match at end using only LIKE (no index) - $v = $database->escapeStr($value); - $v = $this->escapeLIKE($v); - $query->where("$tableField LIKE '%$v'"); - break; - - default: - throw new WireException("Unimplemented operator in " . get_class($this) . "::match()"); + $this->tableName = $this->database->escapeTable($tableName); + $this->fieldName = $this->database->escapeCol($fieldName); + $this->operator = $operator; + + foreach($this->methodOperators as $name => $operators) { + if(in_array($operator, $operators)) $this->method = $name; + if($this->method) break; + } + + if(!$this->method) { + throw new WireException("Unimplemented operator in $this::match()"); } - return $this; + if(is_array($value)) { + $this->matchArrayValue($value); + } else { + $method = $this->method; + $this->$method($this->value($value)); + } + + return $this; } /** @@ -155,90 +211,168 @@ class DatabaseQuerySelectFulltext extends Wire { * Note: PageFinder uses its own array-to-value conversion, so this case applies only to other usages outside PageFinder, * such as FieldtypeMulti::getLoadQueryWhere() * - * @param string $tableName - * @param string $fieldName - * @param string $operator * @param array $value - * @return $this * @since 3.0.141 * @throws WireException * */ - protected function matchArrayValue($tableName, $fieldName, $operator, $value) { - if($operator === '~=') { - throw new WireException("Operator ~= is not supported for $fieldName with OR value condition"); + protected function matchArrayValue(array $value) { + + if($this->operator === '~=') { + throw new WireException("Operator ~= is not supported for $this->fieldName with OR value condition"); } + // convert *= operator to %= to make the query possible (avoiding matchContains method) - if($operator === '*=') $operator = '%='; + if($this->operator === '*=') $this->operator = '%='; + $query = $this->query; $this->query = $this->wire(new DatabaseQuerySelect()); + $this->query->bindOption(true, $query->bindOption(true)); + $method = $this->method; + foreach($value as $v) { - $this->match($tableName, $fieldName, $operator, "$v"); + $this->$method($this->value("$v")); } - $query->where(implode(" OR ", $this->query->where)); + + // @todo need to get anything else from substitute query? + $query->where(implode(' OR ', $this->query->where)); + $this->query->copyBindValuesTo($query); $this->query = $query; - return $this; } /** - * @param string $tableName - * @param string $fieldName - * @param string $operator + * Match equals, not equals, less, greater, etc. + * + * @param string $value + * + */ + protected function matchEquals($value) { + $this->query->where("$this->tableField$this->operator?", $value); + } + + /** + * Match LIKE + * * @param string $value * */ - protected function matchContains($tableName, $fieldName, $operator, $value) { + protected function matchLIKE($value) { + $this->query->where("$this->tableField LIKE ?", '%' . $this->escapeLIKE($value) . '%'); + } - $query = $this->query; - $tableField = "$tableName.$fieldName"; - $database = $this->wire('database'); - $v = $database->escapeStr($value); + /** + * Match starts-with + * + * @param string $value + * + */ + protected function matchStart($value) { + $this->query->where("$this->tableField LIKE ?", $this->escapeLIKE($value) . '%'); + } + + /** + * Match ends-with + * + * @param string $value + * + */ + protected function matchEnd($value) { + $this->query->where("$this->tableField LIKE ?", '%' . $this->escapeLIKE($value)); + } + + /** + * Match words + * + * @param string $value + * + */ + protected function matchWords($value) { + $words = preg_split('/[-\s,@]/', $value, -1, PREG_SPLIT_NO_EMPTY); + foreach($words as $word) { + $len = function_exists('mb_strlen') ? mb_strlen($word) : strlen($word); + if(DatabaseStopwords::has($word) || $len < (int) $this->database->getVariable('ft_min_word_len')) { + // word is stop-word or has too short to use fulltext index + $this->matchWordLIKE($word); + } else { + $this->matchContains($word); + } + } + // force it not to match if no words + if(!count($words)) $this->query->where("1>2"); + } + + + /** + * Match contains string + * + * @param string $value + * + */ + protected function matchContains($value) { + + $tableField = $this->tableField(); + $tableName = $this->tableName; + $fieldName = $this->fieldName; + $operator = $this->operator; + $partial = strpos($operator, '~') === false; $not = strpos($operator, '!') === 0; - if($not) $operator = ltrim($operator, '!'); + $match = $not ? 'NOT MATCH' : 'MATCH'; + $wheres = array(); + $against = $this->escapeAGAINST($value); + $booleanValue = $this->getBooleanQueryValue($value, true, $partial); + $operator = ltrim($operator, '!'); + $likeType = ''; + $like = ''; + $n = 0; - $n = 0; do { $scoreField = "_score_{$tableName}_{$fieldName}" . (++$n); - } while(in_array($scoreField, self::$scoreFields)); + // $locateField = "_locate_{$tableName}_{$fieldName}$n"; + } while(in_array($scoreField, self::$scoreFields)); + self::$scoreFields[] = $scoreField; - - $match = $not ? 'NOT MATCH' : 'MATCH'; - $query->select("$match($tableField) AGAINST('$v') AS $scoreField"); - $query->orderby($scoreField . " DESC"); - $partial = $operator != '~=' && $operator != '!~='; - $booleanValue = $database->escapeStr($this->getBooleanQueryValue($value, true, $partial)); + $bindKey = $this->query->bindValueGetKey($against); + $this->query->select("$match($tableField) AGAINST($bindKey) AS $scoreField"); + $this->query->orderby("$scoreField DESC"); + + //$query->select("LOCATE('$against', $tableField) AS $locateField"); + //$query->orderby("$locateField=1 DESC"); + if($booleanValue) { - $j = "$match($tableField) AGAINST('$booleanValue' IN BOOLEAN MODE) "; - } else { - $j = ''; + $bindKey = $this->query->bindValueGetKey($booleanValue); + $wheres[] = "$match($tableField) AGAINST($bindKey IN BOOLEAN MODE)"; } - - if($operator == '^=' || $operator == '$=' || ($operator == '*=' && (!$j || preg_match('/[-\s]/', $v)))) { - // if $operator is a ^begin/$end, or if there are any word separators in a *= operator value - if($operator == '^=' || $operator == '$=') { - $type = $not ? 'NOT RLIKE' : 'RLIKE'; - $v = $database->escapeStr(preg_quote($value)); // note $value not $v - $like = "[[:space:]]*(<[^>]+>)*[[:space:]]*"; - if($operator == '^=') { - $like = "^" . $like . $v; - } else { - $like = $v . '[[:space:]]*[[:punct:]]*' . $like . '$'; - } + if($operator == '^=' || $operator == '$=') { + // starts or ends with + $likeType = $not ? 'NOT RLIKE' : 'RLIKE'; + $likeText = preg_quote($value); + if($operator === '^=') { + // starts with [optional non-visible html or whitespace] plus query text + $like = '^[[:space:]]*(<[^>]+>)*[[:space:]]*' . $likeText; } else { - $type = $not ? 'NOT LIKE' : 'LIKE'; - $v = $this->escapeLIKE($v); - $like = "%$v%"; + // ends with query text, [optional punctuation and non-visible HTML/whitespace] + $like = $likeText . '[[:space:]]*[[:punct:]]*[[:space:]]*(<[^>]+>)*[[:space:]]*$'; } - $j = trim($j); - $j .= (($j ? "AND " : '') . "($tableField $type '$like')"); // note the LIKE is used as a secondary qualifier, so it's not a bottleneck + } else if($operator === '*=' && (!count($wheres) || preg_match('/[-\s]/', $against))) { + // contains *= with word separators, or no existing where (boolean) conditions + $likeType = $not ? 'NOT LIKE' : 'LIKE'; + $likeText = $this->escapeLIKE($value); + $like = "%$likeText%"; } - $query->where($j); + if($like) { + // LIKE is used as a secondary qualifier, so it's not a bottleneck + $bindKey = $this->query->bindValueGetKey($like); + $wheres[] = "($tableField $likeType $bindKey)"; + } + + if(count($wheres)) $this->query->where(implode(' AND ', $wheres)); } + /** * Match a whole word using MySQL LIKE/REGEXP @@ -246,30 +380,15 @@ class DatabaseQuerySelectFulltext extends Wire { * This is useful primarily for short whole words that can't be indexed due to MySQL ft_min_word_len, * or for words that are stop words. It uses a slower REGEXP rather than fulltext index. * - * @param string $tableName - * @param string $fieldName - * @param string $operator - * @param $word + * @param string $word * */ - protected function matchWordLIKE($tableName, $fieldName, $operator, $word) { - $tableField = "$tableName.$fieldName"; - $database = $this->wire('database'); - $v = $database->escapeStr(preg_quote($word)); - $regex = "([[[:blank:][:punct:]]|^)$v([[:blank:][:punct:]]|$)"; - $type = strpos($operator, '!') === 0 ? 'NOT REGEXP' : 'REGEXP'; - $where = "($tableField $type '$regex')"; - $this->query->where($where); - } - - /** - * Get the query that was provided to the constructor - * - * @return DatabaseQuerySelect - * - */ - public function getQuery() { - return $this->query; + protected function matchWordLIKE($word) { + $word = preg_quote($word); + //$regex = "([[:blank:][:punct:]]|^)$v([[:blank:][:punct:]]|$)"; + $regex = "([[:blank:]]|[[:punct:]]|[[space]]|^)$word([[:blank:]]|[[:punct:]]|[[space]]|$)"; + $type = strpos($this->operator, '!') === 0 ? 'NOT REGEXP' : 'REGEXP'; + $this->query->where("($this->tableField $type ?)", $regex); } /** @@ -282,16 +401,15 @@ class DatabaseQuerySelectFulltext extends Wire { * */ protected function getBooleanQueryValue($value, $required = true, $partial = true) { + $newValue = ''; - //$a = preg_split('/[-\s,+*!.?()=;]+/', $value); - $a = preg_split('/[-\s,+*!?()=;]+/', $value); - foreach($a as $k => $v) { + $value = $this->escapeAGAINST($value); + $words = preg_split('/[\s,!?;]+/', $value); + + foreach($words as $k => $v) { $v = trim($v); - if(!strlen($v)) continue; - if(DatabaseStopwords::has($v)) { - continue; - } - if($required) $newValue .= "+$v"; else $newValue .= "$v"; + if(!strlen($v) || DatabaseStopwords::has($v)) continue; + $newValue .= $required ? "+$v" : "$v"; if($partial) $newValue .= "*"; $newValue .= " "; }