sterzycom/kirby/toolkit/lib/database/query.php

923 lines
22 KiB
PHP

<?php
namespace Database;
use A;
use Error;
use Pagination;
use Str;
use Sql;
/**
*
* Database Query
*
* The query builder is used by the Database class
* to build SQL queries in a fluent, jquery-style way
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
class Query {
const ERROR_INVALID_QUERY_METHOD = 0;
protected $database = null;
// The object which should be fetched for each row
protected $fetch = 'Obj';
// The iterator class, which should be used for result sets
protected $iterator = 'Collection';
// An array of bindings for the final query
protected $bindings = array();
// The table name
protected $table;
// The name of the primary key column
protected $primaryKeyName = 'id';
// An array with additional join parameters
protected $join;
// A list of columns, which should be selected
protected $select;
// Boolean for distinct select clauses
protected $distinct;
// Boolean for if exceptions should be thrown on failing queries
protected $fail = false;
// A list of values for update and insert clauses
protected $values;
// WHERE clause
protected $where;
// GROUP BY clause
protected $group;
// HAVING clause
protected $having;
// ORDER BY clause
protected $order;
// The offset, which should be applied to the select query
protected $offset = 0;
// The limit, which should be applied to the select query
protected $limit;
// Boolean to enable query debugging
protected $debug = false;
/**
* Constructor
*
* @param Database $database Database object
* @param string $table Optional name of the table, which should be queried
*/
public function __construct($database, $table) {
$this->database = $database;
$this->table($table);
if(!$this->table) throw new Error('Invalid table ' . $table);
}
/**
* Reset the query class after each db hit
*/
protected function reset() {
$this->join = null;
$this->select = null;
$this->distinct = null;
$this->fail = false;
$this->values = null;
$this->where = null;
$this->group = null;
$this->having = null;
$this->order = null;
$this->offset = null;
$this->limit = null;
$this->debug = null;
}
/**
* Enables query debugging.
* If enabled, the query will return an array with all important info about
* the query instead of actually executing the query and returning results
*
* @param boolean $debug
* @return object
*/
public function debug($debug = true) {
$this->debug = $debug;
return $this;
}
/**
* Enables distinct select clauses.
*
* @param boolean $distinct
* @return object
*/
public function distinct($distinct = true) {
$this->distinct = $distinct;
return $this;
}
/**
* Enables failing queries.
* If enabled queries will no longer fail silently but throw an exception
*
* @param boolean $fail
* @return object
*/
public function fail($fail = true) {
$this->fail = $fail;
return $this;
}
/**
* Sets the object class, which should be fetched
* Set this to array to get a simple array instead of an object
*
* @param string $fetch
* @return object
*/
public function fetch($fetch) {
if(!is_null($fetch)) $this->fetch = $fetch;
return $this;
}
/**
* Sets the iterator class, which should be used for multiple results
* Set this to array to get a simple array instead of an iterator object
*
* @param string $iterator
* @return object
*/
public function iterator($iterator) {
if(!is_null($iterator)) $this->iterator = $iterator;
return $this;
}
/**
* Sets the name of the table, which should be queried
*
* @param string $table
* @return object
*/
public function table($table) {
if(!is_null($table) && $this->database->validateTable($table)) $this->table = $table;
return $this;
}
/**
* Sets the name of the primary key column
*
* @param string $primaryKeyName
* @return object
*/
public function primaryKeyName($primaryKeyName) {
$this->primaryKeyName = $primaryKeyName;
return $this;
}
/**
* Sets the columns, which should be selected from the table
* By default all columns will be selected
*
* @param mixed $select Pass either a string of columns or an array
* @return object
*/
public function select($select) {
$this->select = $select;
return $this;
}
/**
* Adds a new join clause to the query
*
* @param string $table Name of the table, which should be joined
* @param string $on The on clause for this join
* @param string $type The join type. Uses an inner join by default
* @return object
*/
public function join($table, $on, $type = '') {
$join = array(
'table' => $table,
'on' => $on,
'type' => $type
);
$this->join[] = $join;
return $this;
}
/**
* Shortcut for creating a left join clause
*
* @param string $table Name of the table, which should be joined
* @param string $on The on clause for this join
* @return object
*/
public function leftJoin($table, $on) {
return $this->join($table, $on, 'left');
}
/**
* Shortcut for creating a right join clause
*
* @param string $table Name of the table, which should be joined
* @param string $on The on clause for this join
* @return object
*/
public function rightJoin($table, $on) {
return $this->join($table, $on, 'right');
}
/**
* Shortcut for creating an inner join clause
*
* @param string $table Name of the table, which should be joined
* @param string $on The on clause for this join
* @return object
*/
public function innerJoin($table, $on) {
return $this->join($table, $on, 'inner');
}
/**
* Sets the values which should be used for the update or insert clause
*
* @param mixed $values Can either be a string or an array of values
* @return object
*/
public function values($values = array()) {
if(!is_null($values)) $this->values = $values;
return $this;
}
/**
* Attaches additional bindings to the query.
* Also can be used as getter for all attached bindings by not passing an argument.
*
* @param mixed $bindings Array of bindings or null to use this method as getter
* @return mixed
*/
public function bindings($bindings = null) {
if(is_array($bindings)) {
$this->bindings = array_merge($this->bindings, $bindings);
return $this;
}
return $this->bindings;
}
/**
* Attaches an additional where clause
*
* All available ways to add where clauses
*
* ->where('username like "myuser"'); (args: 1)
* ->where(array('username' => 'myuser')); (args: 1)
* ->where(function($where) { $where->where('id', '=', 1) }) (args: 1)
* ->where('username like ?', 'myuser') (args: 2)
* ->where('username', 'like', 'myuser'); (args: 3)
*
* @param list
* @return object
*/
public function where() {
$this->where = $this->filterQuery(func_get_args(), $this->where);
return $this;
}
/**
* Shortcut to attach a where clause with an OR operator.
* Check out the where() method docs for additional info.
*
* @param list
* @return object
*/
public function orWhere() {
$args = func_get_args();
$mode = a::last($args);
// if there's a where clause mode attribute attached…
if(in_array($mode, array('AND', 'OR'))) {
// remove that from the list of arguments
array_pop($args);
}
// make sure to always attach the OR mode indicator
$args[] = 'OR';
call_user_func_array(array($this, 'where'), $args);
return $this;
}
/**
* Shortcut to attach a where clause with an AND operator.
* Check out the where() method docs for additional info.
*
* @param list
* @return object
*/
public function andWhere() {
$args = func_get_args();
$mode = a::last($args);
// if there's a where clause mode attribute attached…
if(in_array($mode, array('AND', 'OR'))) {
// remove that from the list of arguments
array_pop($args);
}
// make sure to always attach the AND mode indicator
$args[] = 'AND';
call_user_func_array(array($this, 'where'), func_get_args());
return $this;
}
/**
* Attaches a group by clause
*
* @param string $group
* @return object
*/
public function group($group) {
$this->group = $group;
return $this;
}
/**
* Attaches an additional having clause
*
* All available ways to add having clauses
*
* ->having('username like "myuser"'); (args: 1)
* ->having(array('username' => 'myuser')); (args: 1)
* ->having(function($having) { $having->having('id', '=', 1) }) (args: 1)
* ->having('username like ?', 'myuser') (args: 2)
* ->having('username', 'like', 'myuser'); (args: 3)
*
* @param list
* @return object
*/
public function having() {
$this->having = $this->filterQuery(func_get_args(), $this->having);
return $this;
}
/**
* Attaches an order clause
*
* @param string $order
* @return object
*/
public function order($order) {
$this->order = $order;
return $this;
}
/**
* Sets the offset for select clauses
*
* @param int $offset
* @return object
*/
public function offset($offset) {
$this->offset = $offset;
return $this;
}
/**
* Sets the limit for select clauses
*
* @param int $limit
* @return object
*/
public function limit($limit) {
$this->limit = $limit;
return $this;
}
/**
* Builds the different types of SQL queries
* This uses the SQL class to build stuff.
*
* @param string $type (select, update, insert)
* @return string The final query
*/
public function build($type) {
$sql = new SQL($this->database, $this);
switch($type) {
case 'select':
return $sql->select(array(
'table' => $this->table,
'columns' => $this->select,
'join' => $this->join,
'distinct' => $this->distinct,
'where' => $this->where,
'group' => $this->group,
'having' => $this->having,
'order' => $this->order,
'offset' => $this->offset,
'limit' => $this->limit
));
case 'update':
return $sql->update(array(
'table' => $this->table,
'where' => $this->where,
'values' => $this->values,
));
case 'insert':
return $sql->insert(array(
'table' => $this->table,
'values' => $this->values,
));
case 'delete':
return $sql->delete(array(
'table' => $this->table,
'where' => $this->where,
));
}
}
/**
* Builds a count query
*
* @return object
*/
public function count() {
return $this->aggregate('COUNT');
}
/**
* Builds a max query
*
* @param string $column
* @return object
*/
public function max($column) {
return $this->aggregate('MAX', $column);
}
/**
* Builds a min query
*
* @param string $column
* @return object
*/
public function min($column) {
return $this->aggregate('MIN', $column);
}
/**
* Builds a sum query
*
* @param string $column
* @return object
*/
public function sum($column) {
return $this->aggregate('SUM', $column);
}
/**
* Builds an average query
*
* @param string $column
* @return object
*/
public function avg($column) {
return $this->aggregate('AVG', $column);
}
/**
* Builds an aggregation query.
* This is used by all the aggregation methods above
*
* @param string $method
* @param string $column
* @param string $default An optional default value, which should be returned if the query fails
* @return object
*/
public function aggregate($method, $column = '*', $default = 0) {
// reset the sorting to avoid counting issues
$this->order = null;
// validate column
if($column !== '*') {
$sql = new SQL($this->database, $this);
list($table, $columnPart) = $sql->splitIdentifier($this->table, $column);
if(!$this->database->validateColumn($table, $columnPart)) {
throw new Error('Invalid column ' . $column);
}
$column = $sql->combineIdentifier($table, $columnPart);
}
$fetch = $this->fetch;
$row = $this->select($method . '(' . $column . ') as aggregation')->fetch('Obj')->first();
$result = $row ? $row->get('aggregation') : $default;
$this->fetch($fetch);
return $result;
}
/**
* Used as an internal shortcut for firing a db query
*
* @param string $query
* @param array $params
* @return mixed
*/
protected function query($query, $params = array()) {
if($this->debug) return array(
'query' => $query,
'bindings' => $this->bindings(),
'options' => $params
);
if($this->fail) $this->database->fail();
$result = $this->database->query($query, $this->bindings(), $params);
$this->reset();
return $result;
}
/**
* Used as an internal shortcut for executing a db query
*
* @param string $query
* @param array $params
* @return mixed
*/
protected function execute($query, $params = array()) {
if($this->debug) return array(
'query' => $query,
'bindings' => $this->bindings(),
'options' => $params
);
if($this->fail) $this->database->fail();
$result = $this->database->execute($query, $this->bindings(), $params);
$this->reset();
return $result;
}
/**
* Selects only one row from a table
*
* @return object
*/
public function first() {
return $this->query($this->offset(0)->limit(1)->build('select'), array(
'fetch' => $this->fetch,
'iterator' => 'array',
'method' => 'fetch',
));
}
/**
* Selects only one row from a table
*
* @return object
*/
public function row() {
return $this->first();
}
/**
* Selects only one row from a table
*
* @return object
*/
public function one() {
return $this->first();
}
/**
* Automatically adds pagination to a query
*
* @param int $page
* @param int $limit The number of rows, which should be returned for each page
* @param array $params Optional params for the pagination object
* @return object Collection iterator with attached pagination object
*/
public function page($page, $limit, $params = array()) {
$defaults = array(
'page' => $page
);
$options = array_merge($defaults, $params);
// clone this to create a counter query
$counter = clone $this;
// count the total number of rows for this query
$count = $counter->count();
// pagination
$pagination = new Pagination($count, $limit, $options);
// apply it to the dataset and retrieve all rows. make sure to use Collection as the iterator to be able to attach the pagination object
$collection = $this->offset($pagination->offset())->limit($pagination->limit())->all();
// store all pagination vars in a separate object
if($collection) $collection->paginate($pagination);
// return the limited collection
return $collection;
}
/**
* Returns all matching rows from a table
*
* @return mixed
*/
public function all() {
return $this->query($this->build('select'), array(
'fetch' => $this->fetch,
'iterator' => $this->iterator,
));
}
/**
* Returns only values from a single column
*
* @param string $column
* @return mixed
*/
public function column($column) {
$sql = new SQL($this->database, $this);
$primaryKey = $sql->combineIdentifier($this->table, $this->primaryKeyName);
$results = $this->query($this->select(array($column))->order($primaryKey . ' ASC')->build('select'), array(
'iterator' => 'array',
'fetch' => 'array',
));
$results = a::extract($results, $column);
if($this->iterator == 'array') return $results;
$iterator = $this->iterator;
return new $iterator($results);
}
/**
* Find a single row by column and value
*
* @param string $column
* @param mixed $value
* @return mixed
*/
public function findBy($column, $value) {
return $this->where(array($column => $value))->first();
}
/**
* Find a single row by its primary key
*
* @param mixed $id
* @return mixed
*/
public function find($id) {
return $this->findBy($this->primaryKeyName, $id);
}
/**
* Fires an insert query
*
* @param array $values You can pass values here or set them with ->values() before
* @return mixed Returns the last inserted id on success or false.
*/
public function insert($values = null) {
$query = $this->execute($this->values($values)->build('insert'));
return ($query) ? $this->database->lastId() : false;
}
/**
* Fires an update query
*
* @param array $values You can pass values here or set them with ->values() before
* @param mixed $where You can pass a where clause here or set it with ->where() before
* @return boolean
*/
public function update($values = null, $where = null) {
return $this->execute($this->values($values)->where($where)->build('update'));
}
/**
* Fires a delete query
*
* @param mixed $where You can pass a where clause here or set it with ->where() before
* @return boolean
*/
public function delete($where = null) {
return $this->execute($this->where($where)->build('delete'));
}
/**
* Enables magic queries like findByUsername or findByEmail
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call($method, $arguments) {
if(preg_match('!^findBy([a-z]+)!i', $method, $match)) {
$column = str::lower($match[1]);
return $this->findBy($column, $arguments[0]);
} else {
throw new Error('Invalid query method: ' . $method, static::ERROR_INVALID_QUERY_METHOD);
}
}
/**
* Builder for where and having clauses
*
* @param array $args Arguments, see where() description
* @param string $current Current value (like $this->where)
* @return string
*/
protected function filterQuery($args, $current) {
$mode = a::last($args);
$result = '';
// if there's a where clause mode attribute attached…
if(in_array($mode, array('AND', 'OR'))) {
// remove that from the list of arguments
array_pop($args);
} else {
$mode = 'AND';
}
switch(count($args)) {
case 1:
if(is_null($args[0])) {
return $current;
// ->where('username like "myuser"');
} else if(is_string($args[0])) {
// simply add the entire string to the where clause
// escaping or using bindings has to be done before calling this method
$result = $args[0];
// ->where(array('username' => 'myuser'));
} else if(is_array($args[0])) {
$sql = new SQL($this->database, $this);
// simple array mode (AND operator)
$result = $sql->values($this->table, $args[0], ' AND ', true, true);
} else if(is_callable($args[0])) {
$query = clone $this;
call_user_func($args[0], $query);
$result = '(' . $query->where . ')';
}
break;
case 2:
// ->where('username like :username', array('username' => 'myuser'))
if(is_string($args[0]) && is_array($args[1])) {
// prepared where clause
$result = $args[0];
// store the bindings
$this->bindings($args[1]);
// ->where('username like ?', 'myuser')
} else if(is_string($args[0]) && is_string($args[1])) {
// prepared where clause
$result = $args[0];
// store the bindings
$this->bindings(array($args[1]));
}
break;
case 3:
// ->where('username', 'like', 'myuser');
if(is_string($args[0]) && is_string($args[1])) {
// validate column
$sql = new SQL($this->database, $this);
list($table, $column) = $sql->splitIdentifier($this->table, $args[0]);
if(!$this->database->validateColumn($table, $column)) {
throw new Error('Invalid column ' . $args[0]);
}
$key = $sql->combineIdentifier($table, $column);
// ->where('username', 'in', array('myuser', 'myotheruser'));
if(is_array($args[2])) {
$predicate = trim(strtoupper($args[1]));
if(!in_array($predicate, array(
'IN', 'NOT IN'
))) throw new Error('Invalid predicate ' . $predicate);
// build a list of bound values
$values = array();
$bindings = array();
foreach($args[2] as $value) {
$valueBinding = sql::generateBindingName('value');
$bindings[$valueBinding] = $value;
$values[] = $valueBinding;
}
// add that to the where clause in parenthesis
$result = $key . ' ' . $predicate . ' (' . implode(', ', $values) . ')';
$this->bindings($bindings);
// ->where('username', 'like', 'myuser');
} else {
$predicate = trim(strtoupper($args[1]));
if(!in_array($predicate, array(
'=', '>=', '>', '<=', '<', '<>', '!=', '<=>',
'IS', 'IS NOT',
'BETWEEN', 'NOT BETWEEN',
'LIKE', 'NOT LIKE',
'SOUNDS LIKE',
'REGEXP', 'NOT REGEXP'
))) throw new Error('Invalid predicate/operator ' . $predicate);
$valueBinding = sql::generateBindingName('value');
$bindings[$valueBinding] = $args[2];
$result = $key . ' ' . $predicate . ' ' . $valueBinding;
$this->bindings($bindings);
}
}
break;
}
// attach the where clause
if(!empty($current)) {
return $current . ' ' . $mode . ' ' . $result;
} else {
return $result;
}
}
}