sterzycom/kirby/toolkit/lib/sql.php

806 lines
22 KiB
PHP

<?php
/**
* SQL
*
* SQL Query builder
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>, Lukas Bestle <lukas@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
class Sql {
// list of literals which should not be escaped in queries
public static $literals = array('NOW()', null);
// sql formatting methods, defined below
public static $methods = array();
// the parent database connection and database query
public $database;
public $dbquery;
// list of bindings by sql query string that defines them
protected $bindings = array();
/**
* Constructor
*
* @param Database $database
* @param Database\Query $dbquery Database query that is used to set the bindings directly
*/
public function __construct($database, $dbquery = null) {
$this->database = $database;
$this->dbquery = $dbquery;
}
/**
* Sets and returns query-specific bindings
*
* @param string $query SQL query string that contains the bindings
* @param array $values Array of bindings to set (null to get the bindings)
* @return array
*/
public function bindings($query, $values = null) {
if(is_null($values)) {
return a::get($this->bindings, $query, array());
} else {
if(!is_null($query)) $this->bindings[$query] = $values;
// directly register bindings if possible
if($this->dbquery) $this->dbquery->bindings($values);
}
}
/**
* Calls an SQL method using the correct database type
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call($method, $arguments) {
$type = $this->database->type();
if(isset(static::$methods[$type][$method])) {
$method = static::$methods[$type][$method];
} else {
// fallback to shared method
if(!isset(static::$methods['_shared'][$method])) {
throw new Error('SQL method ' . $method . ' is not defined for database type ' . $type);
}
$method = static::$methods['_shared'][$method];
}
// pass the sql object as first argument
array_unshift($arguments, $this);
return call($method, $arguments);
}
/**
* Registers a method for a specified database type
* The function must take this SQL object as first parameter and set bindings on it
*
* @param string $name
* @param callable $function
* @param string $type 'mysql', 'sqlite' or '_shared'
*/
public static function registerMethod($name, $function, $type = '_shared') {
if(!isset(static::$methods[$type])) static::$methods[$type] = array();
static::$methods[$type][$name] = $function;
}
/**
* Returns a randomly generated binding name
*
* @param string $label String that contains lowercase letters and numbers to use as a readable identifier
* @return string
*/
public static function generateBindingName($label) {
// make sure that the binding name is valid to prevent injections
if(!preg_match('/^[a-z0-9]+$/', $label)) $label = 'invalid';
return ':' . $label . '_' . uniqid();
}
}
/**
* Builds a select clause
*
* @param array $params List of parameters for the select clause. Check out the defaults for more info.
* @return string
*/
sql::registerMethod('select', function($sql, $params = array()) {
$defaults = array(
'table' => '',
'columns' => '*',
'join' => false,
'distinct' => false,
'where' => false,
'group' => false,
'having' => false,
'order' => false,
'offset' => 0,
'limit' => false,
);
$options = array_merge($defaults, $params);
$query = array();
$bindings = array();
$query[] = 'SELECT';
// select distinct values
if($options['distinct']) $query[] = 'DISTINCT';
// validate table
if(!$sql->database->validateTable($options['table'])) throw new Error('Invalid table ' . $options['table']);
// columns
if(empty($options['columns'])) {
$query[] = '*';
} else if(is_array($options['columns'])) {
// validate columns
$columns = array();
foreach($options['columns'] as $column) {
list($table, $columnPart) = $sql->splitIdentifier($options['table'], $column);
if(!$sql->database->validateColumn($table, $columnPart)) {
throw new Error('Invalid column ' . $column);
}
$columns[] = $sql->combineIdentifier($table, $columnPart);
}
$query[] = implode(', ', $columns);
} else {
$query[] = $options['columns'];
}
// table
$query[] = 'FROM ' . $sql->quoteIdentifier($options['table']);
// join
if(!empty($options['join'])) {
foreach($options['join'] as $join) {
$joinType = ltrim(strtoupper(a::get($join, 'type', '')) . ' JOIN');
if(!in_array($joinType, array(
'JOIN', 'INNER JOIN',
'OUTER JOIN',
'LEFT OUTER JOIN', 'LEFT JOIN',
'RIGHT OUTER JOIN', 'RIGHT JOIN',
'FULL OUTER JOIN', 'FULL JOIN',
'NATURAL JOIN',
'CROSS JOIN',
'SELF JOIN'
))) throw new Error('Invalid join type ' . $joinType);
// validate table
if(!$sql->database->validateTable($join['table'])) throw new Error('Invalid table ' . $join['table']);
// ON can't be escaped here
$query[] = $joinType . ' ' . $sql->quoteIdentifier($join['table']) . ' ON ' . $join['on'];
}
}
// where
if(!empty($options['where'])) {
// WHERE can't be escaped here
$query[] = 'WHERE ' . $options['where'];
}
// group
if(!empty($options['group'])) {
// GROUP BY can't be escaped here
$query[] = 'GROUP BY ' . $options['group'];
}
// having
if(!empty($options['having'])) {
// HAVING can't be escaped here
$query[] = 'HAVING ' . $options['having'];
}
// order
if(!empty($options['order'])) {
// ORDER BY can't be escaped here
$query[] = 'ORDER BY ' . $options['order'];
}
// offset and limit
if($options['offset'] > 0 || $options['limit']) {
if(!$options['limit']) $options['limit'] = '18446744073709551615';
$offsetBinding = sql::generateBindingName('offset');
$bindings[$offsetBinding] = $options['offset'];
$limitBinding = sql::generateBindingName('limit');
$bindings[$limitBinding] = $options['limit'];
$query[] = 'LIMIT ' . $offsetBinding . ', ' . $limitBinding;
}
$query = implode(' ', $query);
$sql->bindings($query, $bindings);
return $query;
});
/**
* Builds an insert clause
*
* @param array $params List of parameters for the insert clause. See defaults for more info.
* @return string
*/
sql::registerMethod('insert', function($sql, $params = array()) {
$defaults = array(
'table' => '',
'values' => false,
);
$options = array_merge($defaults, $params);
$query = array();
$bindings = array();
// validate table
if(!$sql->database->validateTable($options['table'])) throw new Error('Invalid table ' . $options['table']);
$query[] = 'INSERT INTO ' . $sql->quoteIdentifier($options['table']);
$query[] = $sql->values($options['table'], $options['values'], ', ', false);
$query = implode(' ', $query);
$sql->bindings($query, $bindings);
return $query;
});
/**
* Builds an update clause
*
* @param array $params List of parameters for the update clause. See defaults for more info.
* @return string
*/
sql::registerMethod('update', function($sql, $params = array()) {
$defaults = array(
'table' => '',
'values' => false,
'where' => false,
);
$options = array_merge($defaults, $params);
$query = array();
$bindings = array();
// validate table
if(!$sql->database->validateTable($options['table'])) throw new Error('Invalid table ' . $options['table']);
$query[] = 'UPDATE ' . $sql->quoteIdentifier($options['table']) . ' SET';
$query[] = $sql->values($options['table'], $options['values']);
if(!empty($options['where'])) {
// WHERE can't be escaped here
$query[] = 'WHERE ' . $options['where'];
}
$query = implode(' ', $query);
$sql->bindings($query, $bindings);
return $query;
});
/**
* Builds a delete clause
*
* @param array $params List of parameters for the delete clause. See defaults for more info.
* @return string
*/
sql::registerMethod('delete', function($sql, $params = array()) {
$defaults = array(
'table' => '',
'where' => false,
);
$options = array_merge($defaults, $params);
$query = array();
$bindings = array();
// validate table
if(!$sql->database->validateTable($options['table'])) throw new Error('Invalid table ' . $options['table']);
$query[] = 'DELETE FROM ' . $sql->quoteIdentifier($options['table']);
if(!empty($options['where'])) {
// WHERE can't be escaped here
$query[] = 'WHERE ' . $options['where'];
}
$query = implode(' ', $query);
$sql->bindings($query, $bindings);
return $query;
});
/**
* Builds a safe list of values for insert, select or update queries
*
* @param string $table Table name
* @param mixed $values A value string or array of values
* @param string $separator A separator which should be used to join values
* @param boolean $set If true builds a set list of values for update clauses
* @param boolean $enforceQualified Always use fully qualified column names
* @return string
*/
sql::registerMethod('values', function($sql, $table, $values, $separator = ', ', $set = true, $enforceQualified = false) {
if(!is_array($values)) return $values;
if($set) {
$output = array();
$bindings = array();
foreach($values as $key => $value) {
// validate column
list($table, $column) = $sql->splitIdentifier($table, $key);
if(!$sql->database->validateColumn($table, $column)) {
throw new Error('Invalid column ' . $key);
}
$key = $sql->combineIdentifier($table, $column, $enforceQualified !== true);
if(in_array($value, sql::$literals, true)) {
$output[] = $key . ' = ' . (($value === null)? 'null' : $value);
continue;
} elseif(is_array($value)) {
$value = json_encode($value);
}
$valueBinding = sql::generateBindingName('value');
$bindings[$valueBinding] = $value;
$output[] = $key . ' = ' . $valueBinding;
}
$sql->bindings(null, $bindings);
return implode($separator, $output);
} else {
$fields = array();
$output = array();
$bindings = array();
foreach($values as $key => $value) {
// validate column
list($table, $column) = $sql->splitIdentifier($table, $key);
if(!$sql->database->validateColumn($table, $column)) {
throw new Error('Invalid column ' . $key);
}
$key = $sql->combineIdentifier($table, $column, $enforceQualified !== true);
$fields[] = $key;
if(in_array($value, sql::$literals, true)) {
$output[] = ($value === null)? 'null' : $value;
continue;
} elseif(is_array($value)) {
$value = json_encode($value);
}
$valueBinding = sql::generateBindingName('value');
$bindings[$valueBinding] = $value;
$output[] = $valueBinding;
}
$sql->bindings(null, $bindings);
return '(' . implode($separator, $fields) . ') VALUES (' . implode($separator, $output) . ')';
}
});
/**
* Creates the sql for dropping a single table
*
* @param string $table
* @return string
*/
sql::registerMethod('dropTable', function($sql, $table) {
// validate table
if(!$sql->database->validateTable($table)) throw new Error('Invalid table ' . $table);
return 'DROP TABLE ' . $sql->quoteIdentifier($table);
});
/**
* Creates a table with a simple scheme array for columns
* MySQL version
*
* @todo add more options per column
* @param string $table The table name
* @param array $columns
* @return string
*/
sql::registerMethod('createTable', function($sql, $table, $columns = array()) {
$output = array();
$keys = array();
$bindings = array();
foreach($columns as $name => $column) {
// column type
if(!isset($column['type'])) throw new Error('No column type given for column ' . $name);
switch($column['type']) {
case 'id':
$template = '{column.name} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT';
$column['key'] = 'PRIMARY';
break;
case 'varchar':
$template = '{column.name} varchar(255) {column.null} {column.default}';
break;
case 'text':
$template = '{column.name} TEXT';
break;
case 'int':
$template = '{column.name} INT(11) UNSIGNED {column.null} {column.default}';
break;
case 'timestamp':
$template = '{column.name} TIMESTAMP {column.null} {column.default}';
break;
default:
throw new Error('Unsupported column type: ' . $column['type']);
}
// null
if(a::get($column, 'null') === false) {
$null = 'NOT NULL';
} else {
$null = 'NULL';
}
// indexes/keys
$key = false;
if(isset($column['key'])) {
$column['key'] = strtoupper($column['key']);
// backwards compatibility
if($column['key'] === 'PRIMARY') $column['key'] = 'PRIMARY KEY';
if(in_array($column['key'], array('PRIMARY KEY', 'INDEX'))) {
$key = $column['key'];
$keys[$name] = $key;
}
}
// default value
$defaultBinding = null;
if(isset($column['default'])) {
$defaultBinding = sql::generateBindingName('default');
$bindings[$defaultBinding] = $column['default'];
}
$output[] = trim(str::template($template, array(
'column.name' => $sql->quoteIdentifier($name),
'column.null' => $null,
'column.default' => r(!is_null($defaultBinding), 'DEFAULT ' . $defaultBinding),
)));
}
// combine columns
$inner = implode(',' . PHP_EOL, $output);
// add keys
foreach($keys as $name => $key) {
$inner .= ',' . PHP_EOL . $key . ' (' . $sql->quoteIdentifier($name) . ')';
}
// make it a string
$query = 'CREATE TABLE ' . $sql->quoteIdentifier($table) . ' (' . PHP_EOL . $inner . PHP_EOL . ')';
$sql->bindings($query, $bindings);
return $query;
}, 'mysql');
/**
* Creates a table with a simple scheme array for columns
* SQLite version
*
* @todo add more options per column
* @param string $table The table name
* @param array $columns
* @return string
*/
sql::registerMethod('createTable', function($sql, $table, $columns = array()) {
$output = array();
$keys = array();
$bindings = array();
foreach($columns as $name => $column) {
// column type
if(!isset($column['type'])) throw new Error('No column type given for column ' . $name);
switch($column['type']) {
case 'id':
$template = '{column.name} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE';
break;
case 'varchar':
$template = '{column.name} TEXT {column.null} {column.key} {column.default}';
break;
case 'text':
$template = '{column.name} TEXT {column.null} {column.key} {column.default}';
break;
case 'int':
$template = '{column.name} INTEGER {column.null} {column.key} {column.default}';
break;
case 'timestamp':
$template = '{column.name} INTEGER {column.null} {column.key} {column.default}';
break;
default:
throw new Error('Unsupported column type: ' . $column['type']);
}
// null
if(a::get($column, 'null') === false) {
$null = 'NOT NULL';
} else {
$null = 'NULL';
}
// indexes/keys
$key = false;
if(isset($column['key'])) {
$column['key'] = strtoupper($column['key']);
// backwards compatibility
if($column['key'] === 'PRIMARY') $column['key'] = 'PRIMARY KEY';
if(in_array($column['key'], array('PRIMARY KEY', 'INDEX'))) {
$key = $column['key'];
$keys[$name] = $key;
}
}
// default value
$default = null;
if(isset($column['default'])) {
// Apparently SQLite doesn't support bindings for default values
$default = "'" . $sql->database->escape($column['default']) . "'";
}
$output[] = trim(str::template($template, array(
'column.name' => $sql->quoteIdentifier($name),
'column.null' => $null,
'column.key' => r($key && $key != 'INDEX', $key),
'column.default' => r(!is_null($default), 'DEFAULT ' . $default),
)));
}
// combine columns
$inner = implode(',' . PHP_EOL, $output);
// make it a string
$query = 'CREATE TABLE ' . $sql->quoteIdentifier($table) . ' (' . PHP_EOL . $inner . PHP_EOL . ')';
// set bindings for our first query
$sql->bindings($query, $bindings);
// add index keys
foreach($keys as $name => $key) {
if($key != 'INDEX') continue;
$indexQuery = 'CREATE INDEX ' . $sql->quoteIdentifier($table . '_' . $name) . ' ON ' . $sql->quoteIdentifier($table) . ' (' . $sql->quoteIdentifier($name) . ')';
$query .= ';' . PHP_EOL . $indexQuery;
}
return $query;
}, 'sqlite');
/**
* Splits a (qualified) identifier into table and column
*
* @param $table string Default table if the identifier is not qualified
* @param $identifier string
* @return array
*/
sql::registerMethod('splitIdentifier', function($sql, $table, $identifier) {
// split by dot, but only outside of quotes
$parts = preg_split('/(?:`[^`]*`|"[^"]*")(*SKIP)(*F)|\./', $identifier);
switch(count($parts)) {
// non-qualified identifier
case 1:
return array($table, $sql->unquoteIdentifier($parts[0]));
// qualified identifier
case 2:
return array($sql->unquoteIdentifier($parts[0]), $sql->unquoteIdentifier($parts[1]));
// every other number is an error
default:
throw new Error('Invalid identifier ' . $identifier);
}
});
/**
* Unquotes an identifier (table *or* column)
*
* @param $identifier string
* @return string
*/
sql::registerMethod('unquoteIdentifier', function($sql, $identifier) {
// remove quotes around the identifier
if(in_array(str::substr($identifier, 0, 1), array('"', '`'))) $identifier = str::substr($identifier, 1);
if(in_array(str::substr($identifier, -1), array('"', '`'))) $identifier = str::substr($identifier, 0, -1);
// unescape duplicated quotes
return str_replace(array('""', '``'), array('"', '`'), $identifier);
});
/**
* Combines an identifier (table and column)
* MySQL version
*
* @param $table string
* @param $column string
* @param $values boolean Whether the identifier is going to be used for a values clause
* Only relevant for SQLite
* @return string
*/
sql::registerMethod('combineIdentifier', function($sql, $table, $column, $values = false) {
return $sql->quoteIdentifier($table) . '.' . $sql->quoteIdentifier($column);
}, 'mysql');
/**
* Combines an identifier (table and column)
* SQLite version
*
* @param $table string
* @param $column string
* @param $values boolean Whether the identifier is going to be used for a values clause
* Only relevant for SQLite
* @return string
*/
sql::registerMethod('combineIdentifier', function($sql, $table, $column, $values = false) {
// SQLite doesn't support qualified column names for VALUES clauses
if($values) return $sql->quoteIdentifier($column);
return $sql->quoteIdentifier($table) . '.' . $sql->quoteIdentifier($column);
}, 'sqlite');
/**
* Quotes an identifier (table *or* column)
* MySQL version
*
* @param $identifier string
* @return string
*/
sql::registerMethod('quoteIdentifier', function($sql, $identifier) {
// * is special
if($identifier === '*') return $identifier;
// replace every backtick with two backticks
$identifier = str_replace('`', '``', $identifier);
// wrap in backticks
return '`' . $identifier . '`';
}, 'mysql');
/**
* Quotes an identifier (table *or* column)
* SQLite version
*
* @param $identifier string
* @return string
*/
sql::registerMethod('quoteIdentifier', function($sql, $identifier) {
// * is special
if($identifier === '*') return $identifier;
// replace every quote with two quotes
$identifier = str_replace('"', '""', $identifier);
// wrap in quotes
return '"' . $identifier . '"';
}, 'sqlite');
/**
* Returns a list of tables for a specified database
* MySQL version
*
* @param string $database The database name
* @return string
*/
sql::registerMethod('tableList', function($sql, $database) {
$bindings = array();
$databaseBinding = sql::generateBindingName('database');
$bindings[$databaseBinding] = $database;
$query = 'SELECT TABLE_NAME AS name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ' . $databaseBinding;
$sql->bindings($query, $bindings);
return $query;
}, 'mysql');
/**
* Returns a list of tables of the database
* SQLite version
*
* @param string $database The database name
* @return string
*/
sql::registerMethod('tableList', function($sql, $database) {
return 'SELECT name FROM sqlite_master WHERE type = "table"';
}, 'sqlite');
/**
* Returns a list of columns for a specified table
* MySQL version
*
* @param string $database The database name
* @param string $table The table name
* @return string
*/
sql::registerMethod('columnList', function($sql, $database, $table) {
$bindings = array();
$databaseBinding = sql::generateBindingName('database');
$bindings[$databaseBinding] = $database;
$tableBinding = sql::generateBindingName('table');
$bindings[$tableBinding] = $table;
$query = 'SELECT COLUMN_NAME AS name FROM INFORMATION_SCHEMA.COLUMNS ';
$query .= 'WHERE TABLE_SCHEMA = ' . $databaseBinding . ' AND TABLE_NAME = ' . $tableBinding;
$sql->bindings($query, $bindings);
return $query;
}, 'mysql');
/**
* Returns a list of columns for a specified table
* SQLite version
*
* @param string $database The database name
* @param string $table The table name
* @return string
*/
sql::registerMethod('columnList', function($sql, $database, $table) {
// validate table
if(!$sql->database->validateTable($table)) throw new Error('Invalid table ' . $table);
return 'PRAGMA table_info(' . $sql->quoteIdentifier($table) . ')';
}, 'sqlite');