silverstripe-framework/model/connect/MySQLDatabase.php
Sam Minnee ae9ab22a8f FIX: Use DELETE FROM instead of TRUNCATE for clearTable
clearTable is mainly used for clearing data between tests. In this case,
there are very few or zero records, and DELETE FROM is quicker than
TRUNCATE, which works by deleting and recreating the table.

This materially speeds up test execution, at least on MySQL.
2018-10-03 13:19:31 +13:00

385 lines
11 KiB
PHP

<?php
/**
* MySQL connector class.
*
* Supported indexes for {@link requireTable()}:
*
* @package framework
* @subpackage model
*/
class MySQLDatabase extends SS_Database {
/**
* Default connection charset (may be overridden in $databaseConfig)
*
* @config
* @var String
*/
private static $connection_charset = null;
public function connect($parameters) {
// Ensure that driver is available (required by PDO)
if(empty($parameters['driver'])) {
$parameters['driver'] = $this->getDatabaseServer();
}
// Set charset
if( empty($parameters['charset'])
&& ($charset = Config::inst()->get('MySQLDatabase', 'connection_charset'))
) {
$parameters['charset'] = $charset;
}
// Set collation
if( empty($parameters['collation'])
&& ($collation = Config::inst()->get('MySQLDatabase', 'connection_collation'))
) {
$parameters['collation'] = $collation;
}
// Notify connector of parameters
$this->connector->connect($parameters);
// This is important!
$this->setSQLMode('ANSI');
if (isset($parameters['timezone'])) {
$this->selectTimezone($parameters['timezone']);
}
// 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);
}
}
/**
* @deprecated 4.0 Use "MySQLDatabase.connection_charset" config setting instead
*/
public static function set_connection_charset($charset = 'utf8') {
Deprecation::notice('4.0', 'Use "MySQLDatabase.connection_charset" config setting instead');
Config::inst()->update('MySQLDatabase', 'connection_charset', $charset);
}
/**
* Sets the SQL mode
*
* @param string $mode Connection mode
*/
public function setSQLMode($mode) {
if (empty($mode)) return;
$this->preparedQuery("SET sql_mode = ?", array($mode));
}
/**
* Sets the system timezone for the database connection
*
* @param string $timezone
*/
public function selectTimezone($timezone) {
if (empty($timezone)) return;
$this->preparedQuery("SET SESSION time_zone = ?", array($timezone));
}
public function supportsCollations() {
return true;
}
public function supportsTimezoneOverride() {
return true;
}
public function getDatabaseServer() {
return "mysql";
}
/**
* The core search engine, used by this class and its subclasses to do fun stuff.
* Searches both SiteTree and File.
*
* @param string $keywords Keywords as a string.
*/
public function searchEngine($classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC",
$extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false
) {
if (!class_exists('SiteTree')) {
throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class');
}
if (!class_exists('File')) {
throw new Exception('MySQLDatabase->searchEngine() requires "File" class');
}
$start = (int)$start;
$pageLength = (int)$pageLength;
$keywords = $this->escapeString($keywords);
$htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8');
$extraFilters = array('SiteTree' => '', 'File' => '');
if ($booleanSearch) $boolean = "IN BOOLEAN MODE";
if ($extraFilter) {
$extraFilters['SiteTree'] = " AND $extraFilter";
if ($alternativeFileFilter)
$extraFilters['File'] = " AND $alternativeFileFilter";
else $extraFilters['File'] = $extraFilters['SiteTree'];
}
// Always ensure that only pages with ShowInSearch = 1 can be searched
$extraFilters['SiteTree'] .= " AND ShowInSearch <> 0";
// File.ShowInSearch was added later, keep the database driver backwards compatible
// by checking for its existence first
$fields = $this->fieldList('File');
if (array_key_exists('ShowInSearch', $fields))
$extraFilters['File'] .= " AND ShowInSearch <> 0";
$limit = $start . ", " . $pageLength;
$notMatch = $invertedMatch
? "NOT "
: "";
if ($keywords) {
$match['SiteTree'] = "
MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean)
+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean)
";
$match['File'] = "MATCH (Filename, Title, Content) AGAINST ('$keywords' $boolean) AND ClassName = 'File'";
// We make the relevance search by converting a boolean mode search into a normal one
$relevanceKeywords = str_replace(array('*', '+', '-'), '', $keywords);
$htmlEntityRelevanceKeywords = str_replace(array('*', '+', '-'), '', $htmlEntityKeywords);
$relevance['SiteTree'] = "MATCH (Title, MenuTitle, Content, MetaDescription) "
. "AGAINST ('$relevanceKeywords') "
. "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')";
$relevance['File'] = "MATCH (Filename, Title, Content) AGAINST ('$relevanceKeywords')";
} else {
$relevance['SiteTree'] = $relevance['File'] = 1;
$match['SiteTree'] = $match['File'] = "1 = 1";
}
// Generate initial DataLists and base table names
$lists = array();
$baseClasses = array('SiteTree' => '', 'File' => '');
foreach ($classesToSearch as $class) {
$lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class], "");
$baseClasses[$class] = '"' . $class . '"';
}
$charset = Config::inst()->get('MySQLDatabase', 'charset');
// Make column selection lists
$select = array(
'SiteTree' => array(
"ClassName", "$baseClasses[SiteTree].\"ID\"", "ParentID",
"Title", "MenuTitle", "URLSegment", "Content",
"LastEdited", "Created",
"Filename" => "_{$charset}''", "Name" => "_{$charset}''",
"Relevance" => $relevance['SiteTree'], "CanViewType"
),
'File' => array(
"ClassName", "$baseClasses[File].\"ID\"", "ParentID",
"Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content",
"LastEdited", "Created",
"Filename", "Name",
"Relevance" => $relevance['File'], "CanViewType" => "NULL"
),
);
// Process and combine queries
$querySQLs = array();
$queryParameters = array();
$totalCount = 0;
foreach ($lists as $class => $list) {
$query = $list->dataQuery()->query();
// There's no need to do all that joining
$query->setFrom(array(str_replace(array('"', '`'), '', $baseClasses[$class]) => $baseClasses[$class]));
$query->setSelect($select[$class]);
$query->setOrderBy(array());
$querySQLs[] = $query->sql($parameters);
$queryParameters = array_merge($queryParameters, $parameters);
$totalCount += $query->unlimitedRowCount();
}
$fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit";
// Get records
$records = $this->preparedQuery($fullQuery, $queryParameters);
$objects = array();
foreach ($records as $record) {
$objects[] = new $record['ClassName']($record);
}
$list = new PaginatedList(new ArrayList($objects));
$list->setPageStart($start);
$list->setPageLength($pageLength);
$list->setTotalItems($totalCount);
// The list has already been limited by the query above
$list->setLimitItems(false);
return $list;
}
public function supportsTransactions() {
return true;
}
public function transactionStart($transactionMode = false, $sessionCharacteristics = false) {
// This sets the isolation level for the NEXT transaction, not the current one.
if ($transactionMode) {
$this->query('SET TRANSACTION ' . $transactionMode);
}
$this->query('START TRANSACTION');
if ($sessionCharacteristics) {
$this->query('SET SESSION TRANSACTION ' . $sessionCharacteristics);
}
}
public function transactionSavepoint($savepoint) {
$this->query("SAVEPOINT $savepoint");
}
public function transactionRollback($savepoint = false) {
if ($savepoint) {
$this->query('ROLLBACK TO ' . $savepoint);
} else {
$this->query('ROLLBACK');
}
}
public function transactionEnd($chain = false) {
$this->query('COMMIT AND ' . ($chain ? '' : 'NO ') . 'CHAIN');
}
public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = null,
$parameterised = false
) {
if ($exact && $caseSensitive === null) {
$comp = ($negate) ? '!=' : '=';
} else {
$comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE';
if ($negate) $comp = 'NOT ' . $comp;
}
if($parameterised) {
return sprintf("%s %s ?", $field, $comp);
} else {
return sprintf("%s %s '%s'", $field, $comp, $value);
}
}
public function formattedDatetimeClause($date, $format) {
preg_match_all('/%(.)/', $format, $matches);
foreach ($matches[1] as $match)
if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) {
user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING);
}
if (preg_match('/^now$/i', $date)) {
$date = "NOW()";
} else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
$date = "'$date'";
}
if ($format == '%U') return "UNIX_TIMESTAMP($date)";
return "DATE_FORMAT($date, '$format')";
}
public function datetimeIntervalClause($date, $interval) {
$interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval);
if (preg_match('/^now$/i', $date)) {
$date = "NOW()";
} else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
$date = "'$date'";
}
return "$date + INTERVAL $interval";
}
public function datetimeDifferenceClause($date1, $date2) {
// First date format
if (preg_match('/^now$/i', $date1)) {
$date1 = "NOW()";
} else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) {
$date1 = "'$date1'";
}
// Second date format
if (preg_match('/^now$/i', $date2)) {
$date2 = "NOW()";
} else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) {
$date2 = "'$date2'";
}
return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)";
}
public function supportsLocks() {
return true;
}
public function canLock($name) {
$id = $this->getLockIdentifier($name);
return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value();
}
public function getLock($name, $timeout = 5) {
$id = $this->getLockIdentifier($name);
// MySQL auto-releases existing locks on subsequent GET_LOCK() calls,
// in contrast to PostgreSQL and SQL Server who stack the locks.
return (bool) $this->query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value();
}
public function releaseLock($name) {
$id = $this->getLockIdentifier($name);
return (bool) $this->query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value();
}
protected function getLockIdentifier($name) {
// Prefix with database name
$dbName = $this->connector->getSelectedDatabase() ;
return $this->escapeString("{$dbName}_{$name}");
}
public function now() {
// MySQL uses NOW() to return the current date/time.
return 'NOW()';
}
public function random() {
return 'RAND()';
}
/**
* Clear all data in a given table
*
* @param string $table Name of table
*/
public function clearTable($table) {
$this->query("DELETE FROM \"$table\"");
// Check if resetting the auto-increment is needed
$autoIncrement = $this->preparedQuery(
'SELECT "AUTO_INCREMENT" FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',
[ $this->getSelectedDatabase(), $table]
)->value();
if ($autoIncrement > 1) {
$this->query("ALTER TABLE \"$table\" AUTO_INCREMENT = 1");
}
}
}