silverstripe-framework/src/ORM/Queries/SQLSelect.php

728 lines
22 KiB
PHP

<?php
namespace SilverStripe\ORM\Queries;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\DB;
use InvalidArgumentException;
/**
* Object representing a SQL SELECT query.
* The various parts of the SQL query can be manipulated individually.
*/
class SQLSelect extends SQLConditionalExpression
{
/**
* An array of SELECT fields, keyed by an optional alias.
*
* @var array
*/
protected $select = [];
/**
* An array of GROUP BY clauses.
*
* @var array
*/
protected $groupby = [];
/**
* An array of having clauses.
* Each item in this array will be in the form of a single-length array
* in the format ['predicate' => [$parameters]]
*
* @var array
*/
protected $having = [];
/**
* If this is true DISTINCT will be added to the SQL.
*
* @var bool
*/
protected $distinct = false;
/**
* An array of ORDER BY clauses, functions. Stores as an associative
* array of column / function to direction.
*
* May be used on SELECT or single table DELETE queries in some adapters
*
* @var array
*/
protected $orderby = [];
/**
* An array containing limit and offset keys for LIMIT clause.
*
* May be used on SELECT or single table DELETE queries in some adapters
*
* @var array
*/
protected $limit = [];
/**
* Construct a new SQLSelect.
*
* @param array|string $select An array of SELECT fields.
* @param array|string $from An array of FROM clauses. The first one should be just the table name.
* Each should be ANSI quoted.
* @param array $where An array of WHERE clauses.
* @param array $orderby An array ORDER BY clause.
* @param array $groupby An array of GROUP BY clauses.
* @param array $having An array of HAVING clauses.
* @param array|string $limit A LIMIT clause or array with limit and offset keys
* @return static
*/
public static function create(
$select = "*",
$from = [],
$where = [],
$orderby = [],
$groupby = [],
$having = [],
$limit = []
) {
return Injector::inst()->createWithArgs(__CLASS__, func_get_args());
}
/**
* Construct a new SQLSelect.
*
* @param array|string $select An array of SELECT fields.
* @param array|string $from An array of FROM clauses. The first one should be just the table name.
* Each should be ANSI quoted.
* @param array $where An array of WHERE clauses.
* @param array $orderby An array ORDER BY clause.
* @param array $groupby An array of GROUP BY clauses.
* @param array $having An array of HAVING clauses.
* @param array|string $limit A LIMIT clause or array with limit and offset keys
*/
public function __construct(
$select = "*",
$from = [],
$where = [],
$orderby = [],
$groupby = [],
$having = [],
$limit = []
) {
parent::__construct($from, $where);
$this->setSelect($select);
$this->setOrderBy($orderby);
$this->setGroupBy($groupby);
$this->setHaving($having);
$this->setLimit($limit);
}
/**
* Set the list of columns to be selected by the query.
*
* <code>
* // pass fields to select as single parameter array
* $query->setSelect(['"Col1"', '"Col2"'])->setFrom('"MyTable"');
*
* // pass fields to select as multiple parameters
* $query->setSelect('"Col1"', '"Col2"')->setFrom('"MyTable"');
*
* // Set a list of selected fields as aliases
* $query->setSelect(['Name' => '"Col1"', 'Details' => '"Col2"'])->setFrom('"MyTable"');
* </code>
*
* @param string|array $fields Field names should be ANSI SQL quoted. Array keys should be unquoted.
* @return $this Self reference
*/
public function setSelect($fields)
{
$this->select = [];
if (func_num_args() > 1) {
$fields = func_get_args();
} elseif (!is_array($fields)) {
$fields = [$fields];
}
return $this->addSelect($fields);
}
/**
* Add to the list of columns to be selected by the query.
*
* @see setSelect for example usage
*
* @param string|array $fields Field names should be ANSI SQL quoted. Array keys should be unquoted.
* @return $this Self reference
*/
public function addSelect($fields)
{
if (func_num_args() > 1) {
$fields = func_get_args();
} elseif (!is_array($fields)) {
$fields = [$fields];
}
foreach ($fields as $idx => $field) {
$this->selectField($field, is_numeric($idx) ? null : $idx);
}
return $this;
}
/**
* Select an additional field.
*
* @param string $field The field to select (ansi quoted SQL identifier or statement)
* @param string|null $alias The alias of that field (unquoted SQL identifier).
* Defaults to the unquoted column name of the $field parameter.
* @return $this Self reference
*/
public function selectField($field, $alias = null)
{
if (!$alias) {
if (preg_match('/"([^"]+)"$/', $field, $matches)) {
$alias = $matches[1];
} else {
$alias = $field;
}
}
$this->select[$alias] = $field;
return $this;
}
/**
* Return the SQL expression for the given field alias.
* Returns null if the given alias doesn't exist.
* See {@link selectField()} for details on alias generation.
*
* @param string $field
* @return string
*/
public function expressionForField($field)
{
return isset($this->select[$field]) ? $this->select[$field] : null;
}
/**
* Set distinct property.
*
* @param bool $value
* @return $this Self reference
*/
public function setDistinct($value)
{
$this->distinct = $value;
return $this;
}
/**
* Get the distinct property.
*
* @return bool
*/
public function getDistinct()
{
return $this->distinct;
}
/**
* Get the limit property.
* @return array
*/
public function getLimit()
{
return $this->limit;
}
/**
* Pass LIMIT clause either as SQL snippet or in array format.
* Internally, limit will always be stored as a map containing the keys 'start' and 'limit'
*
* @param int|string|array|null $limit If passed as a string or array, assumes SQL escaped data.
* Only applies for positive values.
* @param int $offset
* @throws InvalidArgumentException
* @return $this Self reference
*/
public function setLimit($limit, $offset = 0)
{
if ((is_numeric($limit) && $limit < 0) || (is_numeric($offset) && $offset < 0)) {
throw new InvalidArgumentException("SQLSelect::setLimit() only takes positive values");
}
if ($limit === 0) {
Deprecation::notice(
'4.3',
"setLimit(0) is deprecated in SS4. To clear limit, call setLimit(null). " .
"In SS5 a limit of 0 will instead return no records."
);
}
if (is_numeric($limit) && ($limit || $offset)) {
$this->limit = [
'start' => (int)$offset,
'limit' => (int)$limit,
];
} elseif ($limit && is_string($limit)) {
if (strpos($limit, ',') !== false) {
list($start, $innerLimit) = explode(',', $limit, 2);
} else {
list($innerLimit, $start) = explode(' OFFSET ', strtoupper($limit), 2);
}
$this->limit = [
'start' => (int)$start,
'limit' => (int)$innerLimit,
];
} elseif ($limit === null && $offset) {
$this->limit = [
'start' => (int)$offset,
'limit' => $limit
];
} else {
$this->limit = $limit;
}
return $this;
}
/**
* Set ORDER BY clause either as SQL snippet or in array format.
*
* @example $sql->setOrderBy("Column");
* @example $sql->setOrderBy("Column DESC");
* @example $sql->setOrderBy("Column DESC, ColumnTwo ASC");
* @example $sql->setOrderBy("Column", "DESC");
* @example $sql->setOrderBy(["Column" => "ASC", "ColumnTwo" => "DESC"]);
*
* @param string|array $clauses Clauses to add (escaped SQL statement)
* @param string $direction Sort direction, ASC or DESC
*
* @return $this Self reference
*/
public function setOrderBy($clauses = null, $direction = null)
{
$this->orderby = [];
return $this->addOrderBy($clauses, $direction);
}
/**
* Add ORDER BY clause either as SQL snippet or in array format.
*
* @example $sql->addOrderBy("Column");
* @example $sql->addOrderBy("Column DESC");
* @example $sql->addOrderBy("Column DESC, ColumnTwo ASC");
* @example $sql->addOrderBy("Column", "DESC");
* @example $sql->addOrderBy(["Column" => "ASC", "ColumnTwo" => "DESC"]);
*
* @param string|array $clauses Clauses to add (escaped SQL statements)
* @param string $direction Sort direction, ASC or DESC
* @return $this Self reference
*/
public function addOrderBy($clauses = null, $direction = null)
{
if (empty($clauses)) {
return $this;
}
if (is_string($clauses)) {
if (strpos($clauses, "(") !== false) {
$sort = preg_split("/,(?![^()]*+\\))/", $clauses);
} else {
$sort = explode(",", $clauses);
}
$clauses = [];
foreach ($sort as $clause) {
list($column, $direction) = $this->getDirectionFromString($clause, $direction);
$clauses[$column] = $direction;
}
}
if (is_array($clauses)) {
foreach ($clauses as $key => $value) {
if (!is_numeric($key)) {
$column = trim($key);
$columnDir = strtoupper(trim($value));
} else {
list($column, $columnDir) = $this->getDirectionFromString($value);
}
$this->orderby[$column] = $columnDir;
}
} else {
user_error('SQLSelect::orderby() incorrect format for $orderby', E_USER_WARNING);
}
// If sort contains a public function call, let's move the sort clause into a
// separate selected field.
//
// Some versions of MySQL choke if you have a group public function referenced
// directly in the ORDER BY
if ($this->orderby) {
$i = 0;
$orderby = [];
foreach ($this->orderby as $clause => $dir) {
// public function calls and multi-word columns like "CASE WHEN ..."
if (strpos($clause, '(') !== false || strpos($clause, " ") !== false) {
// Move the clause to the select fragment, substituting a placeholder column in the sort fragment.
$clause = trim($clause);
do {
$column = "_SortColumn{$i}";
++$i;
} while (array_key_exists('"' . $column . '"', $this->orderby));
$this->selectField($clause, $column);
$clause = '"' . $column . '"';
}
$orderby[$clause] = $dir;
}
$this->orderby = $orderby;
}
return $this;
}
/**
* Extract the direction part of a single-column order by clause.
*
* @param string $value
* @param string $defaultDirection
* @return array A two element array: [$column, $direction]
*/
private function getDirectionFromString($value, $defaultDirection = null)
{
if (preg_match('/^(.*)(asc|desc)$/i', $value, $matches)) {
$column = trim($matches[1]);
$direction = strtoupper($matches[2]);
} else {
$column = $value;
$direction = $defaultDirection ? $defaultDirection : "ASC";
}
return [$column, $direction];
}
/**
* Returns the current order by as array if not already. To handle legacy
* statements which are stored as strings. Without clauses and directions,
* convert the orderby clause to something readable.
*
* @return array
*/
public function getOrderBy()
{
$orderby = $this->orderby;
if (!$orderby) {
$orderby = [];
}
if (!is_array($orderby)) {
// spilt by any commas not within brackets
$orderby = preg_split('/,(?![^()]*+\\))/', $orderby);
}
foreach ($orderby as $k => $v) {
if (strpos($v, ' ') !== false) {
unset($orderby[$k]);
$rule = explode(' ', trim($v));
$clause = $rule[0];
$dir = (isset($rule[1])) ? $rule[1] : 'ASC';
$orderby[$clause] = $dir;
}
}
return $orderby;
}
/**
* Reverses the order by clause by replacing ASC or DESC references in the
* current order by with it's corollary.
*
* @return $this Self reference
*/
public function reverseOrderBy()
{
$order = $this->getOrderBy();
$this->orderby = [];
foreach ($order as $clause => $dir) {
$dir = (strtoupper($dir) == 'DESC') ? 'ASC' : 'DESC';
$this->addOrderBy($clause, $dir);
}
return $this;
}
/**
* Set a GROUP BY clause.
*
* @param string|array $groupby Escaped SQL statement
* @return $this Self reference
*/
public function setGroupBy($groupby)
{
$this->groupby = [];
return $this->addGroupBy($groupby);
}
/**
* Add a GROUP BY clause.
*
* @param string|array $groupby Escaped SQL statement
* @return $this Self reference
*/
public function addGroupBy($groupby)
{
if (is_array($groupby)) {
$this->groupby = array_merge($this->groupby, $groupby);
} elseif (!empty($groupby)) {
$this->groupby[] = $groupby;
}
return $this;
}
/**
* Set a HAVING clause.
*
* @see SQLSelect::addWhere() for syntax examples
*
* @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
* @param mixed $having,... Unlimited additional predicates
* @return $this Self reference
*/
public function setHaving($having)
{
$having = func_num_args() > 1 ? func_get_args() : $having;
$this->having = [];
return $this->addHaving($having);
}
/**
* Add a HAVING clause
*
* @see SQLSelect::addWhere() for syntax examples
*
* @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
* @param mixed $having,... Unlimited additional predicates
* @return $this Self reference
*/
public function addHaving($having)
{
$having = $this->normalisePredicates(func_get_args());
// If the function is called with an array of items
$this->having = array_merge($this->having, $having);
return $this;
}
/**
* Return a list of HAVING clauses used internally.
* @return array
*/
public function getHaving()
{
return $this->having;
}
/**
* Return a list of HAVING clauses used internally.
*
* @param array $parameters Out variable for parameters required for this query
* @return array
*/
public function getHavingParameterised(&$parameters)
{
$this->splitQueryParameters($this->having, $conditions, $parameters);
return $conditions;
}
/**
* Return a list of GROUP BY clauses used internally.
*
* @return array
*/
public function getGroupBy()
{
return $this->groupby;
}
/**
* Return an itemised select list as a map, where keys are the aliases, and values are the column sources.
* Aliases will always be provided (if the alias is implicit, the alias value will be inferred), and won't be
* quoted.
* E.g., 'Title' => '"SiteTree"."Title"'.
*
* @return array
*/
public function getSelect()
{
return $this->select;
}
/// VARIOUS TRANSFORMATIONS BELOW
/**
* Return the number of rows in this query if the limit were removed. Useful in paged data sets.
*
* @param string $column
* @return int
*/
public function unlimitedRowCount($column = null)
{
// we can't clear the select if we're relying on its output by a HAVING clause
if (count($this->having)) {
$records = $this->execute();
return $records->numRecords();
}
$clone = clone $this;
$clone->limit = null;
$clone->orderby = null;
// Choose a default column
if ($column == null) {
if ($this->groupby) {
// @todo Test case required here
$countQuery = new SQLSelect();
$countQuery->setSelect("count(*)");
$countQuery->setFrom(['(' . $clone->sql($innerParameters) . ') all_distinct']);
$sql = $countQuery->sql($parameters); // $parameters should be empty
$result = DB::prepared_query($sql, $innerParameters);
return (int)$result->value();
} else {
$clone->setSelect(["count(*)"]);
}
} else {
$clone->setSelect(["count($column)"]);
}
$clone->setGroupBy([]);
return (int)$clone->execute()->value();
}
/**
* Returns true if this query can be sorted by the given field.
*
* @param string $fieldName
* @return bool
*/
public function canSortBy($fieldName)
{
$fieldName = preg_replace('/(\s+?)(A|DE)SC$/', '', $fieldName);
return isset($this->select[$fieldName]);
}
/**
* Return the number of rows in this query, respecting limit and offset.
*
* @param string $column Quoted, escaped column name
* @return int
*/
public function count($column = null)
{
// we can't clear the select if we're relying on its output by a HAVING clause
if (!empty($this->having)) {
$records = $this->execute();
return $records->numRecords();
} elseif ($column == null) {
// Choose a default column
if ($this->groupby) {
$column = 'DISTINCT ' . implode(", ", $this->groupby);
} else {
$column = '*';
}
}
$clone = clone $this;
$clone->select = ['Count' => "count($column)"];
$clone->limit = null;
$clone->orderby = null;
$clone->groupby = null;
$count = (int)$clone->execute()->value();
// If there's a limit set, then that limit is going to heavily affect the count
if ($this->limit) {
if ($this->limit['limit'] !== null && $count >= ($this->limit['start'] + $this->limit['limit'])) {
return $this->limit['limit'];
} else {
return max(0, $count - $this->limit['start']);
}
// Otherwise, the count is going to be the output of the SQL query
} else {
return $count;
}
}
/**
* Return a new SQLSelect that calls the given aggregate functions on this data.
*
* @param string $column An aggregate expression, such as 'MAX("Balance")', or a set of them
* (as an escaped SQL statement)
* @param string $alias An optional alias for the aggregate column.
* @return SQLSelect A clone of this object with the given aggregate function
*/
public function aggregate($column, $alias = null)
{
$clone = clone $this;
// don't set an ORDER BY clause if no limit has been set. It doesn't make
// sense to add an ORDER BY if there is no limit, and it will break
// queries to databases like MSSQL if you do so. Note that the reason
// this came up is because DataQuery::initialiseQuery() introduces
// a default sort.
if ($this->limit) {
$clone->setLimit($this->limit);
$clone->setOrderBy($this->orderby);
} else {
$clone->setOrderBy([]);
}
$clone->setGroupBy($this->groupby);
if ($alias) {
$clone->setSelect([]);
$clone->selectField($column, $alias);
} else {
$clone->setSelect($column);
}
return $clone;
}
/**
* Returns a query that returns only the first row of this query
*
* @return SQLSelect A clone of this object with the first row only
*/
public function firstRow()
{
$query = clone $this;
$offset = $this->limit ? $this->limit['start'] : 0;
$query->setLimit(1, $offset);
return $query;
}
/**
* Returns a query that returns only the last row of this query
*
* @return SQLSelect A clone of this object with the last row only
*/
public function lastRow()
{
$query = clone $this;
$offset = $this->limit ? $this->limit['start'] : 0;
// Limit index to start in case of empty results
$index = max($this->count() + $offset - 1, 0);
$query->setLimit(1, $index);
return $query;
}
}