1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-09 08:17:12 +02:00

Major refactor/rewrite of ProcessWire's Database classes (DatabaseQuery, DatabaseQuerySelect, DatabaseQuerySelectFulltext)

This commit is contained in:
Ryan Cramer
2020-05-22 13:25:28 -04:00
parent 99f778f109
commit 06acbe57a3
3 changed files with 704 additions and 321 deletions

View File

@@ -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 itll 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

View File

@@ -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 ";
}
}

View File

@@ -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 .= " ";
}