mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
554 lines
17 KiB
PHP
554 lines
17 KiB
PHP
<?php
|
|
|
|
namespace SilverStripe\ORM\Connect;
|
|
|
|
use SilverStripe\Assets\File;
|
|
use SilverStripe\CMS\Model\SiteTree;
|
|
use SilverStripe\Core\Config\Configurable;
|
|
use SilverStripe\Core\Convert;
|
|
use SilverStripe\ORM\PaginatedList;
|
|
use SilverStripe\ORM\DataList;
|
|
use SilverStripe\ORM\ArrayList;
|
|
use SilverStripe\ORM\DataObject;
|
|
use SilverStripe\ORM\Queries\SQLSelect;
|
|
use Exception;
|
|
|
|
/**
|
|
* MySQL connector class.
|
|
*
|
|
* Supported indexes for {@link requireTable()}
|
|
*
|
|
* You are advised to backup your tables if changing settings on an existing database
|
|
* `connection_charset` and `charset` should be equal, similarly so should `connection_collation` and `collation`
|
|
*/
|
|
class MySQLDatabase extends Database implements TransactionManager
|
|
{
|
|
use Configurable;
|
|
|
|
/**
|
|
* Default connection charset (may be overridden in $databaseConfig)
|
|
*
|
|
* @config
|
|
* @var String
|
|
*/
|
|
private static $connection_charset = 'utf8';
|
|
|
|
/**
|
|
* Default connection collation
|
|
*
|
|
* @config
|
|
* @var string
|
|
*/
|
|
private static $connection_collation = 'utf8_general_ci';
|
|
|
|
/**
|
|
* Default charset
|
|
*
|
|
* @config
|
|
* @var string
|
|
*/
|
|
private static $charset = 'utf8';
|
|
|
|
/**
|
|
* Cache for getTransactionManager()
|
|
*
|
|
* @var TransactionManager
|
|
*/
|
|
private $transactionManager = null;
|
|
|
|
/**
|
|
* Default collation
|
|
*
|
|
* @config
|
|
* @var string
|
|
*/
|
|
private static $collation = 'utf8_general_ci';
|
|
|
|
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 = static::config()->get('connection_charset'))) {
|
|
$parameters['charset'] = $charset;
|
|
}
|
|
|
|
// Set collation
|
|
if (empty($parameters['collation']) && ($collation = static::config()->get('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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the SQL mode
|
|
*
|
|
* @param string $mode Connection mode
|
|
*/
|
|
public function setSQLMode($mode)
|
|
{
|
|
if (empty($mode)) {
|
|
return;
|
|
}
|
|
$this->preparedQuery("SET sql_mode = ?", [$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 = ?", [$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.
|
|
*
|
|
* Caution: While the $keywords argument is escaped for safe use in a query context,
|
|
* you need to ensure that it is also a valid boolean expression when opting into $booleanSearch.
|
|
* For example, the "asterisk" and "greater than" characters have a special meaning in this context,
|
|
* and can only be placed in certain parts of the keywords. You will need to preprocess and sanitise
|
|
* user input accordingly in order to avoid query errors.
|
|
*
|
|
* @param array $classesToSearch
|
|
* @param string $keywords Keywords as a string.
|
|
* @param int $start
|
|
* @param int $pageLength
|
|
* @param string $sortBy
|
|
* @param string $extraFilter
|
|
* @param bool $booleanSearch
|
|
* @param string $alternativeFileFilter
|
|
* @param bool $invertedMatch
|
|
* @return PaginatedList
|
|
* @throws Exception
|
|
*/
|
|
public function searchEngine(
|
|
$classesToSearch,
|
|
$keywords,
|
|
$start,
|
|
$pageLength,
|
|
$sortBy = "Relevance DESC",
|
|
$extraFilter = "",
|
|
$booleanSearch = false,
|
|
$alternativeFileFilter = "",
|
|
$invertedMatch = false
|
|
) {
|
|
$pageClass = SiteTree::class;
|
|
$fileClass = File::class;
|
|
if (!class_exists($pageClass)) {
|
|
throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class');
|
|
}
|
|
if (!class_exists($fileClass)) {
|
|
throw new Exception('MySQLDatabase->searchEngine() requires "File" class');
|
|
}
|
|
|
|
$keywords = $this->escapeString($keywords);
|
|
$htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8');
|
|
|
|
$extraFilters = [$pageClass => '', $fileClass => ''];
|
|
|
|
$boolean = '';
|
|
if ($booleanSearch) {
|
|
$boolean = "IN BOOLEAN MODE";
|
|
}
|
|
|
|
if ($extraFilter) {
|
|
$extraFilters[$pageClass] = " AND $extraFilter";
|
|
|
|
if ($alternativeFileFilter) {
|
|
$extraFilters[$fileClass] = " AND $alternativeFileFilter";
|
|
} else {
|
|
$extraFilters[$fileClass] = $extraFilters[$pageClass];
|
|
}
|
|
}
|
|
|
|
// Always ensure that only pages with ShowInSearch = 1 can be searched
|
|
$extraFilters[$pageClass] .= " AND ShowInSearch <> 0";
|
|
|
|
// File.ShowInSearch was added later, keep the database driver backwards compatible
|
|
// by checking for its existence first
|
|
$fileTable = DataObject::getSchema()->tableName($fileClass);
|
|
$fields = $this->getSchemaManager()->fieldList($fileTable);
|
|
if (array_key_exists('ShowInSearch', $fields)) {
|
|
$extraFilters[$fileClass] .= " AND ShowInSearch <> 0";
|
|
}
|
|
|
|
$limit = (int)$start . ", " . (int)$pageLength;
|
|
|
|
$notMatch = $invertedMatch
|
|
? "NOT "
|
|
: "";
|
|
if ($keywords) {
|
|
$match[$pageClass] = "
|
|
MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean)
|
|
+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean)
|
|
";
|
|
$fileClassSQL = Convert::raw2sql($fileClass);
|
|
$match[$fileClass] = "MATCH (Name, Title) AGAINST ('$keywords' $boolean) AND ClassName = '$fileClassSQL'";
|
|
|
|
// We make the relevance search by converting a boolean mode search into a normal one
|
|
$booleanChars = ['*', '+', '@', '-', '(', ')', '<', '>'];
|
|
$relevanceKeywords = str_replace($booleanChars, '', $keywords);
|
|
$htmlEntityRelevanceKeywords = str_replace($booleanChars, '', $htmlEntityKeywords);
|
|
$relevance[$pageClass] = "MATCH (Title, MenuTitle, Content, MetaDescription) "
|
|
. "AGAINST ('$relevanceKeywords') "
|
|
. "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')";
|
|
$relevance[$fileClass] = "MATCH (Name, Title) AGAINST ('$relevanceKeywords')";
|
|
} else {
|
|
$relevance[$pageClass] = $relevance[$fileClass] = 1;
|
|
$match[$pageClass] = $match[$fileClass] = "1 = 1";
|
|
}
|
|
|
|
// Generate initial DataLists and base table names
|
|
$lists = [];
|
|
$sqlTables = [$pageClass => '', $fileClass => ''];
|
|
foreach ($classesToSearch as $class) {
|
|
$lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class]);
|
|
$sqlTables[$class] = '"' . DataObject::getSchema()->tableName($class) . '"';
|
|
}
|
|
|
|
$charset = static::config()->get('charset');
|
|
|
|
// Make column selection lists
|
|
$select = [
|
|
$pageClass => [
|
|
"ClassName", "{$sqlTables[$pageClass]}.\"ID\"", "ParentID",
|
|
"Title", "MenuTitle", "URLSegment", "Content",
|
|
"LastEdited", "Created",
|
|
"Name" => "_{$charset}''",
|
|
"Relevance" => $relevance[$pageClass], "CanViewType"
|
|
],
|
|
$fileClass => [
|
|
"ClassName", "{$sqlTables[$fileClass]}.\"ID\"", "ParentID",
|
|
"Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content" => "_{$charset}''",
|
|
"LastEdited", "Created",
|
|
"Name",
|
|
"Relevance" => $relevance[$fileClass], "CanViewType" => "NULL"
|
|
],
|
|
];
|
|
|
|
// Process and combine queries
|
|
$querySQLs = [];
|
|
$queryParameters = [];
|
|
$totalCount = 0;
|
|
foreach ($lists as $class => $list) {
|
|
/** @var SQLSelect $query */
|
|
$query = $list->dataQuery()->query();
|
|
|
|
// There's no need to do all that joining
|
|
$query->setFrom($sqlTables[$class]);
|
|
$query->setSelect($select[$class]);
|
|
$query->setOrderBy([]);
|
|
|
|
$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 = [];
|
|
|
|
foreach ($records as $record) {
|
|
$objects[] = new $record['ClassName']($record, DataObject::CREATE_HYDRATED);
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the TransactionManager to handle transactions for this database.
|
|
*
|
|
* @return TransactionManager
|
|
*/
|
|
protected function getTransactionManager()
|
|
{
|
|
if (!$this->transactionManager) {
|
|
// PDOConnector providers this
|
|
if ($this->connector instanceof TransactionManager) {
|
|
$this->transactionManager = new NestedTransactionManager($this->connector);
|
|
// Direct database access does not
|
|
} else {
|
|
$this->transactionManager = new NestedTransactionManager(new MySQLTransactionManager($this));
|
|
}
|
|
}
|
|
return $this->transactionManager;
|
|
}
|
|
public function supportsTransactions()
|
|
{
|
|
return true;
|
|
}
|
|
public function supportsSavepoints()
|
|
{
|
|
return $this->getTransactionManager()->supportsSavepoints();
|
|
}
|
|
|
|
public function transactionStart($transactionMode = false, $sessionCharacteristics = false)
|
|
{
|
|
$this->getTransactionManager()->transactionStart($transactionMode, $sessionCharacteristics);
|
|
}
|
|
|
|
public function transactionSavepoint($savepoint)
|
|
{
|
|
$this->getTransactionManager()->transactionSavepoint($savepoint);
|
|
}
|
|
|
|
public function transactionRollback($savepoint = false)
|
|
{
|
|
return $this->getTransactionManager()->transactionRollback($savepoint);
|
|
}
|
|
|
|
public function transactionDepth()
|
|
{
|
|
return $this->getTransactionManager()->transactionDepth();
|
|
}
|
|
|
|
public function transactionEnd($chain = false)
|
|
{
|
|
$result = $this->getTransactionManager()->transactionEnd();
|
|
|
|
if ($chain) {
|
|
Deprecation::notice('4.4', '$chain argument is deprecated');
|
|
return $this->getTransactionManager()->transactionStart();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* In error condition, set transactionNesting to zero
|
|
*/
|
|
protected function resetTransactionNesting()
|
|
{
|
|
// Check whether to use a connector's built-in transaction methods
|
|
if ($this->connector instanceof TransactionalDBConnector) {
|
|
if ($this->transactionNesting > 0) {
|
|
$this->connector->transactionRollback();
|
|
}
|
|
}
|
|
$this->transactionNesting = 0;
|
|
}
|
|
|
|
public function query($sql, $errorLevel = E_USER_ERROR)
|
|
{
|
|
$this->inspectQuery($sql);
|
|
return parent::query($sql, $errorLevel);
|
|
}
|
|
|
|
public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
|
|
{
|
|
$this->inspectQuery($sql);
|
|
return parent::preparedQuery($sql, $parameters, $errorLevel);
|
|
}
|
|
|
|
/**
|
|
* Inspect a SQL query prior to execution
|
|
*
|
|
* @param string $sql
|
|
*/
|
|
protected function inspectQuery($sql)
|
|
{
|
|
// Any DDL discards transactions.
|
|
// See https://dev.mysql.com/doc/internals/en/transactions-notes-on-ddl-and-normal-transaction.html
|
|
// on why we need to be over-eager
|
|
$isDDL = $this->getConnector()->isQueryDDL($sql);
|
|
if ($isDDL) {
|
|
$this->resetTransactionNesting();
|
|
}
|
|
}
|
|
|
|
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, ['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()";
|
|
} elseif (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()";
|
|
} elseif (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()";
|
|
} elseif (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()";
|
|
} elseif (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 5.7.4 and below auto-releases existing locks on subsequent GET_LOCK() calls.
|
|
// MySQL 5.7.5 and newer allow multiple locks per sessions even with the same name.
|
|
// https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
|
|
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");
|
|
}
|
|
}
|
|
}
|