silverstripe-framework/model/queries/SQLConditionalExpression.php
Sam Minnee 3ee8f505b7 MINORE: Remove training whitespace.
The main benefit of this is so that authors who make use of
.editorconfig don't end up with whitespace changes in their PRs.

Spaces vs. tabs has been left alone, although that could do with a
tidy-up in SS4 after the switch to PSR-1/2.

The command used was this:

for match in '*.ss' '*.css' '*.scss' '*.html' '*.yml' '*.php' '*.js' '*.csv' '*.inc' '*.php5'; do
	find . -path ./thirdparty -not -prune -o -path ./admin/thirdparty -not -prune -o -type f -name "$match" -exec sed -E -i '' 's/[[:space:]]+$//' {} \+
	find . -path ./thirdparty -not -prune -o -path ./admin/thirdparty -not -prune -o -type f -name "$match" | xargs perl -pi -e 's/ +$//'
done
2016-01-07 10:15:54 +13:00

720 lines
22 KiB
PHP

<?php
/**
* Represents a SQL query for an expression which interacts with existing rows
* (SELECT / DELETE / UPDATE) with a WHERE clause
*
* @package framework
* @subpackage model
*/
abstract class SQLConditionalExpression extends SQLExpression {
/**
* An array of WHERE clauses.
*
* Each item in this array will be in the form of a single-length array
* in the format array('predicate' => array($parameters))
*
* @var array
*/
protected $where = array();
/**
* The logical connective used to join WHERE clauses. Defaults to AND.
*
* @var string
*/
protected $connective = 'AND';
/**
* An array of tables. The first one is just the table name.
* Used as the FROM in DELETE/SELECT statements, the INTO in INSERT statements,
* and the target table in UPDATE statements
*
* The keys of this array are the aliases of the tables (unquoted), where the
* values are either the literal table names, or an array with join details.
*
* @see SQLConditionalExpression::addLeftJoin()
*
* @var array
*/
protected $from = array();
/**
* Construct a new SQLInteractExpression.
*
* @param array|string $from An array of Tables (FROM clauses). The first one should be just the table name.
* @param array $where An array of WHERE clauses.
*/
function __construct($from = array(), $where = array()) {
$this->setFrom($from);
$this->setWhere($where);
}
/**
* Sets the list of tables to query from or update
*
* @example $query->setFrom('"MyTable"'); // SELECT * FROM "MyTable"
*
* @param string|array $from Single, or list of, ANSI quoted table names
* @return self
*/
public function setFrom($from) {
$this->from = array();
return $this->addFrom($from);
}
/**
* Add a table to include in the query or update
*
* @example $query->addFrom('"MyTable"'); // SELECT * FROM "MyTable"
*
* @param string|array $from Single, or list of, ANSI quoted table names
* @return self Self reference
*/
public function addFrom($from) {
if(is_array($from)) {
$this->from = array_merge($this->from, $from);
} elseif(!empty($from)) {
$this->from[str_replace(array('"','`'), '', $from)] = $from;
}
return $this;
}
/**
* Set the connective property.
*
* @param string $value either 'AND' or 'OR'
*/
public function setConnective($value) {
$this->connective = $value;
}
/**
* Get the connective property.
*
* @return string 'AND' or 'OR'
*/
public function getConnective() {
return $this->connective;
}
/**
* Use the disjunctive operator 'OR' to join filter expressions in the WHERE clause.
*/
public function useDisjunction() {
$this->setConnective('OR');
}
/**
* Use the conjunctive operator 'AND' to join filter expressions in the WHERE clause.
*/
public function useConjunction() {
$this->setConnective('AND');
}
/**
* Add a LEFT JOIN criteria to the tables list.
*
* @param string $table Unquoted table name
* @param string $onPredicate The "ON" SQL fragment in a "LEFT JOIN ... AS ... ON ..." statement, Needs to be valid
* (quoted) SQL.
* @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
* will cause the query to appear first. The default is 20, and joins created automatically by the
* ORM have a value of 10.
* @param array $parameters Any additional parameters if the join is a parameterised subquery
* @return self Self reference
*/
public function addLeftJoin($table, $onPredicate, $tableAlias = '', $order = 20, $parameters = array()) {
if(!$tableAlias) {
$tableAlias = $table;
}
$this->from[$tableAlias] = array(
'type' => 'LEFT',
'table' => $table,
'filter' => array($onPredicate),
'order' => $order,
'parameters' => $parameters
);
return $this;
}
/**
* Add an INNER JOIN criteria
*
* @param string $table Unquoted table name
* @param string $onPredicate The "ON" SQL fragment in an "INNER JOIN ... AS ... ON ..." statement. Needs to be
* valid (quoted) SQL.
* @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on
* @param int $order A numerical index to control the order that joins are added to the query; lower order
* values will cause the query to appear first. The default is 20, and joins created automatically by the
* ORM have a value of 10.
* @param array $parameters Any additional parameters if the join is a parameterised subquery
* @return self Self reference
*/
public function addInnerJoin($table, $onPredicate, $tableAlias = null, $order = 20, $parameters = array()) {
if(!$tableAlias) $tableAlias = $table;
$this->from[$tableAlias] = array(
'type' => 'INNER',
'table' => $table,
'filter' => array($onPredicate),
'order' => $order,
'parameters' => $parameters
);
return $this;
}
/**
* Add an additional filter (part of the ON clause) on a join.
*
* @param string $table Table to join on from the original join (unquoted)
* @param string $filter The "ON" SQL fragment (escaped)
* @return self Self reference
*/
public function addFilterToJoin($table, $filter) {
$this->from[$table]['filter'][] = $filter;
return $this;
}
/**
* Set the filter (part of the ON clause) on a join.
*
* @param string $table Table to join on from the original join (unquoted)
* @param string $filter The "ON" SQL fragment (escaped)
* @return self Self reference
*/
public function setJoinFilter($table, $filter) {
$this->from[$table]['filter'] = array($filter);
return $this;
}
/**
* Returns true if we are already joining to the given table alias
*
* @param string $tableAlias Table name
* @return boolean
*/
public function isJoinedTo($tableAlias) {
return isset($this->from[$tableAlias]);
}
/**
* Return a list of tables that this query is selecting from.
*
* @return array Unquoted table names
*/
public function queriedTables() {
$tables = array();
foreach($this->from as $key => $tableClause) {
if(is_array($tableClause)) {
$table = '"'.$tableClause['table'].'"';
} else if(is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause, $matches)) {
$table = $matches[1];
} else {
$table = $tableClause;
}
// Handle string replacements
if($this->replacementsOld) $table = str_replace($this->replacementsOld, $this->replacementsNew, $table);
$tables[] = preg_replace('/^"|"$/','',$table);
}
return $tables;
}
/**
* Return a list of tables queried
*
* @return array
*/
public function getFrom() {
return $this->from;
}
/**
* Retrieves the finalised list of joins
*
* @todo This part of the code could be simplified
*
* @param array $parameters Out variable for parameters required for this query
* @return array List of joins as a mapping from array('Alias' => 'Join Expression')
*/
public function getJoins(&$parameters = array()) {
if(func_num_args() == 0) {
Deprecation::notice(
'4.0',
'SQLConditionalExpression::getJoins() now may produce parameters which are necessary to
execute this query'
);
}
// Sort the joins
$parameters = array();
$joins = $this->getOrderedJoins($this->from);
// Build from clauses
foreach($joins as $alias => $join) {
// $join can be something like this array structure
// array('type' => 'inner', 'table' => 'SiteTree', 'filter' => array("SiteTree.ID = 1",
// "Status = 'approved'", 'order' => 20))
if(!is_array($join)) continue;
if(is_string($join['filter'])) {
$filter = $join['filter'];
} elseif(sizeof($join['filter']) == 1) {
$filter = $join['filter'][0];
} else {
$filter = "(" . implode(") AND (", $join['filter']) . ")";
}
// Ensure tables are quoted, unless the table is actually a sub-select
$table = preg_match('/\bSELECT\b/i', $join['table'])
? $join['table']
: "\"{$join['table']}\"";
$aliasClause = ($alias != $join['table'])
? " AS \"{$alias}\""
: "";
$joins[$alias] = strtoupper($join['type']) . " JOIN " . $table . "$aliasClause ON $filter";
if(!empty($join['parameters'])) {
$parameters = array_merge($parameters, $join['parameters']);
}
}
return $joins;
}
/**
* Ensure that framework "auto-generated" table JOINs are first in the finalised SQL query.
* This prevents issues where developer-initiated JOINs attempt to JOIN using relations that haven't actually
* yet been scaffolded by the framework. Demonstrated by PostGres in errors like:
*"...ERROR: missing FROM-clause..."
*
* @param $from array - in the format of $this->from
* @return array - and reorderded list of selects
*/
protected function getOrderedJoins($from) {
// shift the first FROM table out from so we only deal with the JOINs
$baseFrom = array_shift($from);
$this->mergesort($from, function($firstJoin, $secondJoin) {
if(
!is_array($firstJoin)
|| !is_array($secondJoin)
|| $firstJoin['order'] == $secondJoin['order']
) {
return 0;
} else {
return ($firstJoin['order'] < $secondJoin['order']) ? -1 : 1;
}
});
// Put the first FROM table back into the results
array_unshift($from, $baseFrom);
return $from;
}
/**
* Since uasort don't preserve the order of an array if the comparison is equal
* we have to resort to a merge sort. It's quick and stable: O(n*log(n)).
*
* @see http://stackoverflow.com/q/4353739/139301
*
* @param array &$array - the array to sort
* @param callable $cmpFunction - the function to use for comparison
*/
protected function mergesort(&$array, $cmpFunction = 'strcmp') {
// Arrays of size < 2 require no action.
if (count($array) < 2) {
return;
}
// Split the array in half
$halfway = count($array) / 2;
$array1 = array_slice($array, 0, $halfway);
$array2 = array_slice($array, $halfway);
// Recurse to sort the two halves
$this->mergesort($array1, $cmpFunction);
$this->mergesort($array2, $cmpFunction);
// If all of $array1 is <= all of $array2, just append them.
if(call_user_func($cmpFunction, end($array1), reset($array2)) < 1) {
$array = array_merge($array1, $array2);
return;
}
// Merge the two sorted arrays into a single sorted array
$array = array();
$val1 = reset($array1);
$val2 = reset($array2);
do {
if (call_user_func($cmpFunction, $val1, $val2) < 1) {
$array[key($array1)] = $val1;
$val1 = next($array1);
} else {
$array[key($array2)] = $val2;
$val2 = next($array2);
}
} while($val1 && $val2);
// Merge the remainder
while($val1) {
$array[key($array1)] = $val1;
$val1 = next($array1);
}
while($val2) {
$array[key($array2)] = $val2;
$val2 = next($array2);
}
return;
}
/**
* Set a WHERE clause.
*
* @see SQLConditionalExpression::addWhere() for syntax examples
*
* @param mixed $where Predicate(s) to set, as escaped SQL statements or paramaterised queries
* @param mixed $where,... Unlimited additional predicates
* @return self Self reference
*/
public function setWhere($where) {
$where = func_num_args() > 1 ? func_get_args() : $where;
$this->where = array();
return $this->addWhere($where);
}
/**
* Adds a WHERE clause.
*
* Note that the database will execute any parameterised queries using
* prepared statements whenever available.
*
* There are several different ways of doing this.
*
* <code>
* // the entire predicate as a single string
* $query->addWhere("\"Column\" = 'Value'");
*
* // multiple predicates as an array
* $query->addWhere(array("\"Column\" = 'Value'", "\"Column\" != 'Value'"));
*
* // Shorthand for the above using argument expansion
* $query->addWhere("\"Column\" = 'Value'", "\"Column\" != 'Value'");
*
* // multiple predicates with parameters
* $query->addWhere(array('"Column" = ?' => $column, '"Name" = ?' => $value)));
*
* // Shorthand for simple column comparison (as above), omitting the '?'
* $query->addWhere(array('"Column"' => $column, '"Name"' => $value));
*
* // Multiple predicates, each with multiple parameters.
* $query->addWhere(array(
* '"ColumnOne" = ? OR "ColumnTwo" != ?' => array(1, 4),
* '"ID" != ?' => $value
* ));
*
* // Using a dynamically generated condition (any object that implements SQLConditionGroup)
* $condition = new ObjectThatImplements_SQLConditionGroup();
* $query->addWhere($condition);
*
* </code>
*
* Note that if giving multiple parameters for a single predicate the array
* of values must be given as an indexed array, not an associative array.
*
* Also should be noted is that any null values for parameters may give unexpected
* behaviour. array('Column' => NULL) is shorthand for array('Column = ?', NULL), and
* will not match null values for that column, as 'Column IS NULL' is the correct syntax.
*
* Additionally, be careful of key conflicts. Adding two predicates with the same
* condition but different parameters can cause a key conflict if added in the same array.
* This can be solved by wrapping each individual condition in an array. E.g.
*
* <code>
* // Multiple predicates with duplicate conditions
* $query->addWhere(array(
* array('ID != ?' => 5),
* array('ID != ?' => 6)
* ));
*
* // Alternatively this can be added in two separate calls to addWhere
* $query->addWhere(array('ID != ?' => 5));
* $query->addWhere(array('ID != ?' => 6));
*
* // Or simply omit the outer array
* $query->addWhere(array('ID != ?' => 5), array('ID != ?' => 6));
* </code>
*
* If it's necessary to force the parameter to be considered as a specific data type
* by the database connector's prepared query processor any parameter can be cast
* to that type by using the following format.
*
* <code>
* // Treat this value as a double type, regardless of its type within PHP
* $query->addWhere(array(
* 'Column' => array(
* 'value' => $variable,
* 'type' => 'double'
* )
* ));
* </code>
*
* @param mixed $where Predicate(s) to set, as escaped SQL statements or paramaterised queries
* @param mixed $where,... Unlimited additional predicates
* @return self Self reference
*/
public function addWhere($where) {
$where = $this->normalisePredicates(func_get_args());
// If the function is called with an array of items
$this->where = array_merge($this->where, $where);
return $this;
}
/**
* @see SQLConditionalExpression::addWhere()
*
* @param mixed $filters Predicate(s) to set, as escaped SQL statements or paramaterised queries
* @param mixed $filters,... Unlimited additional predicates
* @return self Self reference
*/
public function setWhereAny($filters) {
$filters = func_num_args() > 1 ? func_get_args() : $filters;
return $this
->setWhere(array())
->addWhereAny($filters);
}
/**
* @see SQLConditionalExpression::addWhere()
*
* @param mixed $filters Predicate(s) to set, as escaped SQL statements or paramaterised queries
* @param mixed $filters,... Unlimited additional predicates
* @return self Self reference
*/
public function addWhereAny($filters) {
// Parse and split predicates along with any parameters
$filters = $this->normalisePredicates(func_get_args());
$this->splitQueryParameters($filters, $predicates, $parameters);
$clause = "(".implode(") OR (", $predicates).")";
return $this->addWhere(array($clause => $parameters));
}
/**
* Return a list of WHERE clauses used internally.
*
* @return array
*/
public function getWhere() {
return $this->where;
}
/**
* Return a list of WHERE clauses used internally.
*
* @param array $parameters Out variable for parameters required for this query
* @return array
*/
public function getWhereParameterised(&$parameters) {
$this->splitQueryParameters($this->where, $predicates, $parameters);
return $predicates;
}
/**
* Given a key / value pair, extract the predicate and any potential paramaters
* in a format suitable for storing internally as a list of paramaterised conditions.
*
* @param string|integer $key The left hand (key index) of this condition.
* Could be the predicate or an integer index.
* @param mixed $value The The right hand (array value) of this condition.
* Could be the predicate (if non-paramaterised), or the parameter(s). Could also be
* an array containing a nested condition in the similar format this function outputs.
* @return array|SQLConditionGroup A single item array in the format
* array($predicate => array($parameters)), unless it's a SQLConditionGroup
*/
protected function parsePredicate($key, $value) {
// If a string key is given then presume this is a paramaterised condition
if($value instanceof SQLConditionGroup) {
return $value;
} elseif(is_string($key)) {
// Extract the parameter(s) from the value
if(!is_array($value) || isset($value['type'])) {
$parameters = array($value);
} else {
$parameters = array_values($value);
}
// Append '= ?' if not present, parameters are given, and we have exactly one parameter
if(strpos($key, '?') === FALSE) {
$parameterCount = count($parameters);
if($parameterCount === 1) {
$key .= " = ?";
} elseif($parameterCount > 1) {
user_error("Incorrect number of '?' in predicate $key. Expected $parameterCount but none given.",
E_USER_ERROR);
}
}
return array($key => $parameters);
} elseif(is_array($value)) {
// If predicates are nested one per array (as per the internal format)
// then run a quick check over the contents and recursively parse
if(count($value) != 1) {
user_error('Nested predicates should be given as a single item array in '
. 'array($predicate => array($prameters)) format)', E_USER_ERROR);
}
foreach($value as $key => $value) {
return $this->parsePredicate($key, $value);
}
} else {
// Non-paramaterised condition
return array($value => array());
}
}
/**
* Given a list of conditions in any user-acceptable format, convert this
* to an array of paramaterised predicates suitable for merging with $this->where.
*
* Normalised predicates are in the below format, in order to avoid key collisions.
*
* <code>
* array(
* array('Condition != ?' => array('parameter')),
* array('Condition != ?' => array('otherparameter')),
* array('Condition = 3' => array()),
* array('Condition = ? OR Condition = ?' => array('parameter1', 'parameter2))
* )
* </code>
*
* @param array $predicates List of predicates. These should be wrapped in an array
* one level more than for addWhere, as query expansion is not supported here.
* @return array List of normalised predicates
*/
protected function normalisePredicates(array $predicates) {
// Since this function is called with func_get_args we should un-nest the single first parameter
if(count($predicates) == 1) $predicates = array_shift($predicates);
// Ensure single predicates are iterable
if(!is_array($predicates)) $predicates = array($predicates);
$normalised = array();
foreach($predicates as $key => $value) {
if(empty($value) && (empty($key) || is_numeric($key))) continue; // Ignore empty conditions
$normalised[] = $this->parsePredicate($key, $value);
}
return $normalised;
}
/**
* Given a list of conditions as per the format of $this->where, split
* this into an array of predicates, and a separate array of ordered parameters
*
* Note, that any SQLConditionGroup objects will be evaluated here.
* @see SQLConditionGroup
*
* @param array $conditions List of Conditions including parameters
* @param array $predicates Out parameter for the list of string predicates
* @param array $parameters Out parameter for the list of parameters
*/
public function splitQueryParameters($conditions, &$predicates, &$parameters) {
// Merge all filters with paramaterised queries
$predicates = array();
$parameters = array();
foreach($conditions as $condition) {
// Evaluate the result of SQLConditionGroup here
if($condition instanceof SQLConditionGroup) {
$conditionSQL = $condition->conditionSQL($conditionParameters);
if(!empty($conditionSQL)) {
$predicates[] = $conditionSQL;
$parameters = array_merge($parameters, $conditionParameters);
}
} else {
foreach($condition as $key => $value) {
$predicates[] = $key;
$parameters = array_merge($parameters, $value);
}
}
}
}
/**
* Checks whether this query is for a specific ID in a table
*
* @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ID=5")
*
* @return boolean
*/
public function filtersOnID() {
$regexp = '/^(.*\.)?("|`)?ID("|`)?\s?=/';
// @todo - Test this works with paramaterised queries
foreach($this->getWhereParameterised($parameters) as $predicate) {
if(preg_match($regexp, $predicate)) return true;
}
return false;
}
/**
* Checks whether this query is filtering on a foreign key, ie finding a has_many relationship
*
* @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ParentID=5")
*
* @return boolean
*/
public function filtersOnFK() {
$regexp = '/^(.*\.)?("|`)?[a-zA-Z]+ID("|`)?\s?=/';
// @todo - Test this works with paramaterised queries
foreach($this->getWhereParameterised($parameters) as $predicate) {
if(preg_match($regexp, $predicate)) return true;
}
return false;
}
public function isEmpty() {
return empty($this->from);
}
/**
* Generates an SQLDelete object using the currently specified parameters
*
* @return SQLDelete
*/
public function toDelete() {
$delete = new SQLDelete();
$this->copyTo($delete);
return $delete;
}
/**
* Generates an SQLSelect object using the currently specified parameters.
*
* @return SQLSelect
*/
public function toSelect() {
$select = new SQLSelect();
$this->copyTo($select);
return $select;
}
/**
* Generates an SQLUpdate object using the currently specified parameters.
* No fields will have any assigned values for the newly generated SQLUpdate
* object.
*
* @return SQLUpdate
*/
public function toUpdate() {
$update = new SQLUpdate();
$this->copyTo($update);
return $update;
}
}