silverstripe-framework/src/ORM/Connect/Database.php

956 lines
30 KiB
PHP

<?php
namespace SilverStripe\ORM\Connect;
use SilverStripe\Control\Director;
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\Debug;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\PaginatedList;
use SilverStripe\ORM\Queries\SQLUpdate;
use SilverStripe\ORM\Queries\SQLInsert;
use BadMethodCallException;
use Exception;
use SilverStripe\Dev\Backtrace;
/**
* Abstract database connectivity class.
* Sub-classes of this implement the actual database connection libraries
*/
abstract class Database
{
const PARTIAL_QUERY = 'partial_query';
const FULL_QUERY = 'full_query';
/**
* To use, call from _config.php
* Example:
* <code>
* Database::setWhitelistQueryArray([
* 'Qualmark' => 'partial_query',
* 'SELECT "Version" FROM "SiteTree_Live" WHERE "ID" = ?' => 'full_query',
* ])
* </code>
* @var array
*/
protected static $whitelist_array = [];
/**
* Database connector object
*
* @var DBConnector
*/
protected $connector = null;
/**
* In cases where your environment does not have 'SHOW DATABASES' permission,
* you can set this to true. Then selectDatabase() will always connect without
* doing databaseExists() check.
*
* @var bool
*/
private static $optimistic_connect = false;
/**
* Amount of queries executed, for debugging purposes.
*
* @var int
*/
protected $queryCount = 0;
/**
* Get the current connector
*
* @return DBConnector
*/
public function getConnector()
{
return $this->connector;
}
/**
* Injector injection point for connector dependency
*
* @param DBConnector $connector
*/
public function setConnector(DBConnector $connector)
{
$this->connector = $connector;
}
/**
* Database schema manager object
*
* @var DBSchemaManager
*/
protected $schemaManager = null;
/**
* Returns the current schema manager
*
* @return DBSchemaManager
*/
public function getSchemaManager()
{
return $this->schemaManager;
}
/**
* Injector injection point for schema manager
*
* @param DBSchemaManager $schemaManager
*/
public function setSchemaManager(DBSchemaManager $schemaManager)
{
$this->schemaManager = $schemaManager;
if ($this->schemaManager) {
$this->schemaManager->setDatabase($this);
}
}
/**
* Query builder object
*
* @var DBQueryBuilder
*/
protected $queryBuilder = null;
/**
* Returns the current query builder
*
* @return DBQueryBuilder
*/
public function getQueryBuilder()
{
return $this->queryBuilder;
}
/**
* Injector injection point for schema manager
*
* @param DBQueryBuilder $queryBuilder
*/
public function setQueryBuilder(DBQueryBuilder $queryBuilder)
{
$this->queryBuilder = $queryBuilder;
}
/**
* Execute the given SQL query.
*
* @param string $sql The SQL query to execute
* @param int $errorLevel The level of error reporting to enable for the query
* @return Query
*/
public function query($sql, $errorLevel = E_USER_ERROR)
{
// Check if we should only preview this query
if ($this->previewWrite($sql)) {
return null;
}
// Benchmark query
$connector = $this->connector;
return $this->benchmarkQuery(
$sql,
function ($sql) use ($connector, $errorLevel) {
return $connector->query($sql, $errorLevel);
}
);
}
/**
* Execute the given SQL parameterised query with the specified arguments
*
* @param string $sql The SQL query to execute. The ? character will denote parameters.
* @param array $parameters An ordered list of arguments.
* @param int $errorLevel The level of error reporting to enable for the query
* @return Query
*/
public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
{
// Check if we should only preview this query
if ($this->previewWrite($sql)) {
return null;
}
// Benchmark query
$connector = $this->connector;
return $this->benchmarkQuery(
$sql,
function ($sql) use ($connector, $parameters, $errorLevel) {
return $connector->preparedQuery($sql, $parameters, $errorLevel);
},
$parameters
);
}
/**
* Determines if the query should be previewed, and thus interrupted silently.
* If so, this function also displays the query via the debuging system.
* Subclasess should respect the results of this call for each query, and not
* execute any queries that generate a true response.
*
* @param string $sql The query to be executed
* @return boolean Flag indicating that the query was previewed
*/
protected function previewWrite($sql)
{
// Only preview if previewWrite is set, we are in dev mode, and
// the query is mutable
if (isset($_REQUEST['previewwrite'])
&& Director::isDev()
&& $this->connector->isQueryMutable($sql)
) {
// output preview message
Debug::message("Will execute: $sql");
return true;
} else {
return false;
}
}
/**
* Allows the display and benchmarking of queries as they are being run
*
* @param string $sql Query to run, and single parameter to callback
* @param callable $callback Callback to execute code
* @param array $parameters Parameters for any parameterised query
* @return mixed Result of query
*/
protected function benchmarkQuery($sql, $callback, $parameters = [])
{
if (isset($_REQUEST['showqueries']) && Director::isDev()) {
$displaySql = true;
$this->queryCount++;
$starttime = microtime(true);
$result = $callback($sql);
$endtime = round(microtime(true) - $starttime, 4);
// replace parameters as closely as possible to what we'd expect the DB to put in
if (in_array(strtolower($_REQUEST['showqueries']), ['inline', 'backtrace'])) {
$sql = DB::inline_parameters($sql, $parameters);
} elseif (strtolower($_REQUEST['showqueries']) === 'whitelist') {
$displaySql = false;
foreach (self::$whitelist_array as $query => $searchType) {
$fullQuery = ($searchType === self::FULL_QUERY && $query === $sql);
$partialQuery = ($searchType === self::PARTIAL_QUERY && mb_strpos($sql, $query) !== false);
if (!$fullQuery && !$partialQuery) {
continue;
}
$sql = DB::inline_parameters($sql, $parameters);
$this->displayQuery($sql, $endtime);
}
}
if ($displaySql) {
$this->displayQuery($sql, $endtime);
}
// Show a backtrace if ?showqueries=backtrace
if ($_REQUEST['showqueries'] === 'backtrace') {
Backtrace::backtrace();
}
return $result;
}
return $callback($sql);
}
/**
* Display query message
*
* @param mixed $query
* @param float $endtime
*/
protected function displayQuery($query, $endtime)
{
$queryCount = sprintf("%04d", $this->queryCount);
Debug::message("\n$queryCount: $query\n{$endtime}s\n", false);
}
/**
* Add the sql queries that need to be partially or fully matched
*
* @param array $whitelistArray
*/
public static function setWhitelistQueryArray($whitelistArray)
{
self::$whitelist_array = $whitelistArray;
}
/**
* Get the sql queries that need to be partially or fully matched
*
* @return array
*/
public static function getWhitelistQueryArray()
{
return self::$whitelist_array;
}
/**
* Get the autogenerated ID from the previous INSERT query.
*
* @param string $table The name of the table to get the generated ID for
* @return integer the most recently generated ID for the specified table
*/
public function getGeneratedID($table)
{
return $this->connector->getGeneratedID($table);
}
/**
* Determines if we are connected to a server AND have a valid database
* selected.
*
* @return boolean Flag indicating that a valid database is connected
*/
public function isActive()
{
return $this->connector->isActive();
}
/**
* Returns an escaped string. This string won't be quoted, so would be suitable
* for appending to other quoted strings.
*
* @param mixed $value Value to be prepared for database query
* @return string Prepared string
*/
public function escapeString($value)
{
return $this->connector->escapeString($value);
}
/**
* Wrap a string into DB-specific quotes.
*
* @param mixed $value Value to be prepared for database query
* @return string Prepared string
*/
public function quoteString($value)
{
return $this->connector->quoteString($value);
}
/**
* Escapes an identifier (table / database name). Typically the value
* is simply double quoted. Don't pass in already escaped identifiers in,
* as this will double escape the value!
*
* @param string|array $value The identifier to escape or list of split components
* @param string $separator Splitter for each component
* @return string
*/
public function escapeIdentifier($value, $separator = '.')
{
// Split string into components
if (!is_array($value)) {
$value = explode($separator, $value);
}
// Implode quoted column
return '"' . implode('"' . $separator . '"', $value) . '"';
}
/**
* Escapes unquoted columns keys in an associative array
*
* @param array $fieldValues
* @return array List of field values with the keys as escaped column names
*/
protected function escapeColumnKeys($fieldValues)
{
$out = [];
foreach ($fieldValues as $field => $value) {
$out[$this->escapeIdentifier($field)] = $value;
}
return $out;
}
/**
* Execute a complex manipulation on the database.
* A manipulation is an array of insert / or update sequences. The keys of the array are table names,
* and the values are map containing 'command' and 'fields'. Command should be 'insert' or 'update',
* and fields should be a map of field names to field values, NOT including quotes.
*
* The field values could also be in paramaterised format, such as
* array('MAX(?,?)' => array(42, 69)), allowing the use of raw SQL values such as
* array('NOW()' => array()).
*
* @see SQLWriteExpression::addAssignments for syntax examples
*
* @param array $manipulation
*/
public function manipulate($manipulation)
{
if (empty($manipulation)) {
return;
}
foreach ($manipulation as $table => $writeInfo) {
if (empty($writeInfo['fields'])) {
continue;
}
// Note: keys of $fieldValues are not escaped
$fieldValues = $writeInfo['fields'];
// Switch command type
switch ($writeInfo['command']) {
case "update":
// Build update
$query = new SQLUpdate("\"$table\"", $this->escapeColumnKeys($fieldValues));
// Set best condition to use
if (!empty($writeInfo['where'])) {
$query->addWhere($writeInfo['where']);
} elseif (!empty($writeInfo['id'])) {
$query->addWhere(['"ID"' => $writeInfo['id']]);
}
// Test to see if this update query shouldn't, in fact, be an insert
if ($query->toSelect()->count()) {
$query->execute();
break;
}
// ...if not, we'll skip on to the insert code
case "insert":
// Ensure that the ID clause is given if possible
if (!isset($fieldValues['ID']) && isset($writeInfo['id'])) {
$fieldValues['ID'] = $writeInfo['id'];
}
// Build insert
$query = new SQLInsert("\"$table\"", $this->escapeColumnKeys($fieldValues));
$query->execute();
break;
default:
throw new \InvalidArgumentException(
"SS_Database::manipulate() Can't recognise command '{$writeInfo['command']}'"
);
}
}
}
/**
* Enable supression of database messages.
*/
public function quiet()
{
$this->schemaManager->quiet();
}
/**
* Clear all data out of the database
*/
public function clearAllData()
{
$tables = $this->getSchemaManager()->tableList();
foreach ($tables as $table) {
$this->clearTable($table);
}
}
/**
* Clear all data in a given table
*
* @param string $table Name of table
*/
public function clearTable($table)
{
$this->query("TRUNCATE \"$table\"");
}
/**
* Generates a WHERE clause for null comparison check
*
* @param string $field Quoted field name
* @param bool $isNull Whether to check for NULL or NOT NULL
* @return string Non-parameterised null comparison clause
*/
public function nullCheckClause($field, $isNull)
{
$clause = $isNull
? "%s IS NULL"
: "%s IS NOT NULL";
return sprintf($clause, $field);
}
/**
* Generate a WHERE clause for text matching.
*
* @param string $field Quoted field name
* @param string $value Escaped search. Can include percentage wildcards.
* Ignored if $parameterised is true.
* @param boolean $exact Exact matches or wildcard support.
* @param boolean $negate Negate the clause.
* @param boolean $caseSensitive Enforce case sensitivity if TRUE or FALSE.
* Fallback to default collation if set to NULL.
* @param boolean $parameterised Insert the ? placeholder rather than the
* given value. If this is true then $value is ignored.
* @return String SQL
*/
abstract public function comparisonClause(
$field,
$value,
$exact = false,
$negate = false,
$caseSensitive = null,
$parameterised = false
);
/**
* function to return an SQL datetime expression that can be used with the adapter in use
* used for querying a datetime in a certain format
*
* @param string $date to be formated, can be either 'now', literal datetime like '1973-10-14 10:30:00' or
* field name, e.g. '"SiteTree"."Created"'
* @param string $format to be used, supported specifiers:
* %Y = Year (four digits)
* %m = Month (01..12)
* %d = Day (01..31)
* %H = Hour (00..23)
* %i = Minutes (00..59)
* %s = Seconds (00..59)
* %U = unix timestamp, can only be used on it's own
* @return string SQL datetime expression to query for a formatted datetime
*/
abstract public function formattedDatetimeClause($date, $format);
/**
* function to return an SQL datetime expression that can be used with the adapter in use
* used for querying a datetime addition
*
* @param string $date can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name,
* e.g. '"SiteTree"."Created"'
* @param string $interval to be added, use the format [sign][integer] [qualifier], e.g. -1 Day, +15 minutes,
* +1 YEAR
* supported qualifiers:
* - years
* - months
* - days
* - hours
* - minutes
* - seconds
* This includes the singular forms as well
* @return string SQL datetime expression to query for a datetime (YYYY-MM-DD hh:mm:ss) which is the result of
* the addition
*/
abstract public function datetimeIntervalClause($date, $interval);
/**
* function to return an SQL datetime expression that can be used with the adapter in use
* used for querying a datetime substraction
*
* @param string $date1 can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name
* e.g. '"SiteTree"."Created"'
* @param string $date2 to be substracted of $date1, can be either 'now', literal datetime
* like '1973-10-14 10:30:00' or field name, e.g. '"SiteTree"."Created"'
* @return string SQL datetime expression to query for the interval between $date1 and $date2 in seconds which
* is the result of the substraction
*/
abstract public function datetimeDifferenceClause($date1, $date2);
/**
* String operator for concatenation of strings
*
* @return string
*/
public function concatOperator()
{
// @todo Make ' + ' in mssql
return ' || ';
}
/**
* Returns true if this database supports collations
*
* @return boolean
*/
abstract public function supportsCollations();
/**
* Can the database override timezone as a connection setting,
* or does it use the system timezone exclusively?
*
* @return Boolean
*/
abstract public function supportsTimezoneOverride();
/**
* Query for the version of the currently connected database
* @return string Version of this database
*/
public function getVersion()
{
return $this->connector->getVersion();
}
/**
* Get the database server type (e.g. mysql, postgresql).
* This value is passed to the connector as the 'driver' argument when
* initiating a database connection
*
* @return string
*/
abstract public function getDatabaseServer();
/**
* Return the number of rows affected by the previous operation.
* @return int
*/
public function affectedRows()
{
return $this->connector->affectedRows();
}
/**
* The core search engine, used by this class and its subclasses to do fun stuff.
* Searches both SiteTree and File.
*
* @param array $classesToSearch List of classes to search
* @param string $keywords Keywords as a string.
* @param integer $start Item to start returning results from
* @param integer $pageLength Number of items per page
* @param string $sortBy Sort order expression
* @param string $extraFilter Additional filter
* @param boolean $booleanSearch Flag for boolean search mode
* @param string $alternativeFileFilter
* @param boolean $invertedMatch
* @return PaginatedList Search results
*/
abstract public function searchEngine(
$classesToSearch,
$keywords,
$start,
$pageLength,
$sortBy = "Relevance DESC",
$extraFilter = "",
$booleanSearch = false,
$alternativeFileFilter = "",
$invertedMatch = false
);
/**
* Determines if this database supports transactions
*
* @return boolean Flag indicating support for transactions
*/
abstract public function supportsTransactions();
/**
* Does this database support savepoints in transactions
* By default it is assumed that they don't unless they are explicitly enabled.
*
* @return boolean Flag indicating support for savepoints in transactions
*/
public function supportsSavepoints()
{
return false;
}
/**
* Determines if the used database supports given transactionMode as an argument to startTransaction()
* If transactions are completely unsupported, returns false.
*
* @param string $mode
* @return bool
*/
public function supportsTransactionMode(string $mode): bool
{
// Default implementation: assume all modes are a supported.
return $this->supportsTransactions();
}
/**
* Invoke $callback within a transaction
*
* @param callable $callback Callback to run
* @param callable $errorCallback Optional callback to run after rolling back transaction.
* @param bool|string $transactionMode Optional transaction mode to use
* @param bool $errorIfTransactionsUnsupported If true, this method will fail if transactions are unsupported.
* Otherwise, the $callback will potentially be invoked outside of a transaction.
* @throws Exception
*/
public function withTransaction(
$callback,
$errorCallback = null,
$transactionMode = false,
$errorIfTransactionsUnsupported = false
) {
$supported = $this->supportsTransactions();
if (!$supported && $errorIfTransactionsUnsupported) {
throw new BadMethodCallException("Transactions not supported by this database.");
}
if ($supported) {
$this->transactionStart($transactionMode);
}
try {
call_user_func($callback);
} catch (Exception $ex) {
if ($supported) {
$this->transactionRollback();
}
if ($errorCallback) {
call_user_func($errorCallback);
}
throw $ex;
}
if ($supported) {
$this->transactionEnd();
}
}
/*
* Determines if the current database connection supports a given list of extensions
*
* @param array $extensions List of extensions to check for support of. The key of this array
* will be an extension name, and the value the configuration for that extension. This
* could be one of partitions, tablespaces, or clustering
* @return boolean Flag indicating support for all of the above
* @todo Write test cases
*/
public function supportsExtensions($extensions)
{
return false;
}
/**
* Start a prepared transaction
* See http://developer.postgresql.org/pgdocs/postgres/sql-set-transaction.html for details on
* transaction isolation options
*
* @param string|boolean $transactionMode Transaction mode, or false to ignore
* @param string|boolean $sessionCharacteristics Session characteristics, or false to ignore
*/
abstract public function transactionStart($transactionMode = false, $sessionCharacteristics = false);
/**
* Create a savepoint that you can jump back to if you encounter problems
*
* @param string $savepoint Name of savepoint
*/
abstract public function transactionSavepoint($savepoint);
/**
* Rollback or revert to a savepoint if your queries encounter problems
* If you encounter a problem at any point during a transaction, you may
* need to rollback that particular query, or return to a savepoint
*
* @param string|boolean $savepoint Name of savepoint, or leave empty to rollback
* to last savepoint
* @return bool|null Boolean is returned if success state is known, or null if
* unknown. Note: For error checking purposes null should not be treated as error.
*/
abstract public function transactionRollback($savepoint = false);
/**
* Commit everything inside this transaction so far
*
* @param bool $chain
* @return bool|null Boolean is returned if success state is known, or null if
* unknown. Note: For error checking purposes null should not be treated as error.
*/
abstract public function transactionEnd($chain = false);
/**
* Return depth of current transaction
*
* @return int Nesting level, or 0 if not in a transaction
*/
public function transactionDepth()
{
// Placeholder error for transactional DBs that don't expose depth
if ($this->supportsTransactions()) {
user_error(get_class($this) . " does not support transactionDepth", E_USER_WARNING);
}
return 0;
}
/**
* Determines if the used database supports application-level locks,
* which is different from table- or row-level locking.
* See {@link getLock()} for details.
*
* @return bool Flag indicating that locking is available
*/
public function supportsLocks()
{
return false;
}
/**
* Returns if the lock is available.
* See {@link supportsLocks()} to check if locking is generally supported.
*
* @param string $name Name of the lock
* @return bool
*/
public function canLock($name)
{
return false;
}
/**
* Sets an application-level lock so that no two processes can run at the same time,
* also called a "cooperative advisory lock".
*
* Return FALSE if acquiring the lock fails; otherwise return TRUE, if lock was acquired successfully.
* Lock is automatically released if connection to the database is broken (either normally or abnormally),
* making it less prone to deadlocks than session- or file-based locks.
* Should be accompanied by a {@link releaseLock()} call after the logic requiring the lock has completed.
* Can be called multiple times, in which case locks "stack" (PostgreSQL, SQL Server),
* or auto-releases the previous lock (MySQL).
*
* Note that this might trigger the database to wait for the lock to be released, delaying further execution.
*
* @param string $name Name of lock
* @param integer $timeout Timeout in seconds
* @return bool
*/
public function getLock($name, $timeout = 5)
{
return false;
}
/**
* Remove an application-level lock file to allow another process to run
* (if the execution aborts (e.g. due to an error) all locks are automatically released).
*
* @param string $name Name of the lock
* @return bool Flag indicating whether the lock was successfully released
*/
public function releaseLock($name)
{
return false;
}
/**
* Instruct the database to generate a live connection
*
* @param array $parameters An map of parameters, which should include:
* - server: The server, eg, localhost
* - username: The username to log on with
* - password: The password to log on with
* - database: The database to connect to
* - charset: The character set to use. Defaults to utf8
* - timezone: (optional) The timezone offset. For example: +12:00, "Pacific/Auckland", or "SYSTEM"
* - driver: (optional) Driver name
*/
public function connect($parameters)
{
// Ensure that driver is available (required by PDO)
if (empty($parameters['driver'])) {
$parameters['driver'] = $this->getDatabaseServer();
}
// Notify connector of parameters
$this->connector->connect($parameters);
// SS_Database subclass maintains responsibility for selecting database
// once connected in order to correctly handle schema queries about
// existence of database, error handling at the correct level, etc
if (!empty($parameters['database'])) {
$this->selectDatabase($parameters['database'], false, false);
}
}
/**
* Determine if the database with the specified name exists
*
* @param string $name Name of the database to check for
* @return bool Flag indicating whether this database exists
*/
public function databaseExists($name)
{
return $this->schemaManager->databaseExists($name);
}
/**
* Retrieves the list of all databases the user has access to
*
* @return array List of database names
*/
public function databaseList()
{
return $this->schemaManager->databaseList();
}
/**
* Change the connection to the specified database, optionally creating the
* database if it doesn't exist in the current schema.
*
* @param string $name Name of the database
* @param bool $create Flag indicating whether the database should be created
* if it doesn't exist. If $create is false and the database doesn't exist
* then an error will be raised
* @param int|bool $errorLevel The level of error reporting to enable for the query, or false if no error
* should be raised
* @return bool Flag indicating success
*/
public function selectDatabase($name, $create = false, $errorLevel = E_USER_ERROR)
{
// In case our live environment is locked down, we can bypass a SHOW DATABASE check
$canConnect = Config::inst()->get(static::class, 'optimistic_connect')
|| $this->schemaManager->databaseExists($name);
if ($canConnect) {
return $this->connector->selectDatabase($name);
}
// Check DB creation permisson
if (!$create) {
if ($errorLevel !== false) {
user_error("Attempted to connect to non-existing database \"$name\"", $errorLevel);
}
// Unselect database
$this->connector->unloadDatabase();
return false;
}
$this->schemaManager->createDatabase($name);
return $this->connector->selectDatabase($name);
}
/**
* Drop the database that this object is currently connected to.
* Use with caution.
*/
public function dropSelectedDatabase()
{
$databaseName = $this->connector->getSelectedDatabase();
if ($databaseName) {
$this->connector->unloadDatabase();
$this->schemaManager->dropDatabase($databaseName);
}
}
/**
* Returns the name of the currently selected database
*
* @return string|null Name of the selected database, or null if none selected
*/
public function getSelectedDatabase()
{
return $this->connector->getSelectedDatabase();
}
/**
* Return SQL expression used to represent the current date/time
*
* @return string Expression for the current date/time
*/
abstract public function now();
/**
* Returns the database-specific version of the random() function
*
* @return string Expression for a random value
*/
abstract public function random();
}